[
  {
    "path": ".bundlemonrc",
    "content": "{\n  \"baseDir\": \"./build\",\n  \"pathLabels\": {\n    \"chunkId\": \"[\\\\d-]+\"\n  },\n  \"files\": [\n    {\n      \"path\": \"static/js/<chunkId>.<hash>.js\"\n    },\n    {\n      \"path\": \"static/js/cozy.<hash>.js\"\n    },\n    {\n      \"path\": \"static/js/main.<hash>.js\"\n    },\n    {\n      \"path\": \"static/js/lib-react.<hash>.js\"\n    },\n    {\n      \"path\": \"static/js/lib-router.<hash>.js\"\n    },\n    {\n      \"path\": \"static/css/cozy.<hash>.css\"\n    },\n    {\n      \"path\": \"static/css/main.<hash>.css\"\n    },\n    {\n      \"path\": \"public/<hash>.js\"\n    },\n    {\n      \"path\": \"public/static/js/<chunkId>.<hash>.js\"\n    },\n    {\n      \"path\": \"public/static/js/public.<hash>.js\"\n    },\n    {\n      \"path\": \"public/static/js/cozy.<hash>.js\"\n    },\n    {\n      \"path\": \"public/static/js/lib-react.<hash>.js\"\n    },\n    {\n      \"path\": \"public/static/js/lib-router.<hash>.js\"\n    },\n    {\n      \"path\": \"public/static/css/cozy.<hash>.css\"\n    },\n    {\n      \"path\": \"public/static/css/public.<hash>.css\"\n    },\n    {\n      \"path\": \"services/dacc.js\"\n    },\n    {\n      \"path\": \"services/qualificationMigration.js\"\n    },\n    {\n      \"path\": \"<hash>.js\"\n    },\n    {\n      \"path\": \"index.html\"\n    },\n    {\n      \"path\": \"assets/manifest.json\"\n    },\n    {\n      \"path\": \"manifest.webapp\"\n    }\n  ],\n  \"groups\": [\n      {\n          \"path\": \"**/*.js\"\n      },\n      {\n          \"path\": \"**/*.css\"\n      },\n      {\n          \"path\": \"**/*.{png,svg,ico}\"\n      }\n  ],\n  \"reportOutput\": [\n    \"github\"\n  ]\n}\n"
  },
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ntrim_trailing_whitespace = true\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\n\n[*.{jade,pug}]\ntrim_trailing_whitespace = false\n\n[*.styl]\nindent_size = 4\n\n[*.xml]\nindent_size = 4\n"
  },
  {
    "path": ".github/workflows/ci-cd.yml",
    "content": "name: CI/CD\n\non:\n  pull_request:\n  push:\n    branches:\n      - master\n    tags:\n      - '[0-9]+.[0-9]+.[0-9]+'\n      - '[0-9]+.[0-9]+.[0-9]+-beta.[0-9]+'\n\nenv:\n  MATTERMOST_CHANNEL: '{\"dev\":\"appvengers\",\"beta\":\"appvengers,publication\",\"stable\":\"appvengers,publication\"}'\n  MATTERMOST_HOOK_URL: ${{ secrets.MATTERMOST_HOOK_URL }}\n  REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}\n\njobs:\n  build:\n    name: Build and publish\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n      - name: Use Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version-file: '.nvmrc'\n      - name: Install dependencies\n        run: yarn install --frozen-lockfile\n      - name: Lint\n        run: yarn lint\n      - name: Test\n        run: yarn test\n      - name: Build\n        run: yarn build\n      - name: BundleMon\n        uses: lironer/bundlemon-action@v1\n        continue-on-error: true\n      - name: Set SSH for downcloud\n        if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')\n        uses: webfactory/ssh-agent@v0.9.0\n        with:\n          ssh-private-key: ${{ secrets.DOWNCLOUD_SSH_KEY }}\n      - name: Publish\n        if: github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')\n        run: yarn run cozyPublish --yes\n"
  },
  {
    "path": ".github/workflows/codeql-analysis.yml",
    "content": "# For most projects, this workflow file will not need changing; you simply need\n# to commit it to your repository.\n#\n# You may wish to alter this file to override the set of languages analyzed,\n# or to provide custom queries or build logic.\n#\n# ******** NOTE ********\n# We have attempted to detect the languages in your repository. Please check\n# the `language` matrix defined below to confirm you have the correct set of\n# supported CodeQL languages.\n#\nname: \"CodeQL\"\n\non:\n  push:\n    branches: [ master, prod ]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [ master ]\n  schedule:\n    - cron: '26 3 * * 1'\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'javascript' ]\n        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]\n        # Learn more:\n        # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v2\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v1\n      with:\n        languages: ${{ matrix.language }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n        # queries: ./path/to/local/query, your-org/your-repo/queries@main\n\n    # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java).\n    # If this step fails, then you should remove it and run the build manually (see below)\n    - name: Autobuild\n      uses: github/codeql-action/autobuild@v1\n\n    # ℹ️ Command-line programs to run using the OS shell.\n    # 📚 https://git.io/JvXDl\n\n    # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines\n    #    and modify them (or add more) to build your code if your project\n    #    uses a compiled language\n\n    #- run: |\n    #   make bootstrap\n    #   make release\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v1\n"
  },
  {
    "path": ".github/workflows/create-bump-pr.yml",
    "content": "name: 'Create Bump PR'\n\non:\n  workflow_dispatch:\n    inputs:\n      version:\n        description: 'New version'\n        required: true\n\npermissions:\n  contents: write\n  pull-requests: write\n\njobs:\n  bump:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v5\n      - name: Create new branch\n        run: |\n          git config --global user.name 'GitHub Actions'\n          git config --global user.email 'actions@github.com'\n          git checkout -b chore/bump-version-${{ inputs.version }}\n      - name: Bump version\n        run: |\n          sed -i 's/\"version\": \"[^\"]*\"/\"version\": \"${{ inputs.version }}\"/g' package.json\n          sed -i 's/\"version\": \"[^\"]*\"/\"version\": \"${{ inputs.version }}\"/g' manifest.webapp\n      - name: Commit changes\n        run: |\n          git add package.json\n          git add manifest.webapp\n          git commit -m \"chore: Bump version to ${{ inputs.version }}\"\n      - name: Push changes\n        run: git push origin chore/bump-version-${{ inputs.version }}\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n      - name: Create Pull Request\n        run: gh pr create -B master -H chore/bump-version-${{ inputs.version }} --title 'Bump ${{ inputs.version }}' --body 'This PR bumps the version to ${{ inputs.version }}.'\n        env:\n          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# NPM\nnode_modules/\nnpm-debug.log\nyarn-error.log\n\n# Build\nbuild/\n\n# Test\ncoverage/\n.consoleUsageReporter.json\n\n# Stack\n./storage/\nstorage/\n\n# Reports\nreports/\n\n# Default\n!.gitkeep\n\n# OS generated files\n.DS_Store\n.DS_Store?\n._*\n.Spotlight-V100\n.Trashes\nehthumbs.db\nThumbs.db\ndesktop.ini\n\n# Editors / IDEs\n.floo\n.flooignore\n.brackets.json\n.vscode\n\n# cozy-jobs-cli\n.token.json\nkonnector-dev-config.json\n\n# SWC\n.swc\n.opencode\n"
  },
  {
    "path": ".nvmrc",
    "content": "20\n"
  },
  {
    "path": ".transifexrc.tpl",
    "content": "[https://www.transifex.com]\nrest_hostname = https://rest.api.transifex.com\n"
  },
  {
    "path": ".tx/config",
    "content": "[main]\nhost = https://www.transifex.com\n\n[o:cozy:p:cozy-drive:r:e8e90edbf54aede8b7b026659d6fe40a]\nfile_filter = src/locales/<lang>.json\nsource_file = src/locales/en.json\nsource_lang = en\ntype        = KEYVALUEJSON\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# 1.45.0\n\n## ✨ Features\n## 🐛 Bug Fixes\n## 🔧 Tech\n\n\n# 1.44.0\n\n## ✨ Features\n\n* Improvements to DACC service\n\n## 🐛 Bug Fixes\n\n* Remove double elevator on file list on public pages\n* Fix issue on file upload\n* Fix display when moving element to the root\n\n## 🔧 Tech\n\n* Use new DACC API\n* Remove verbose mode\n* Add all files for bundlemon and make it drastically sensitive \n\n\n# 1.43.0\n\n## ✨ Features\n\n* Upgrade cozy-bar@7.20.1 to be able to call onSelect function\n* Improve speed of search suggestion by preventing fetch notes url until click\n* Update cozy-stack-client and cozy-pouch-link to sync with cozy-client version\n* Update cozy-ui\n  - Modify Viewers to handle [68.0.0 BC](https://github.com/cozy/cozy-ui/releases/tag/v68.0.0)\n  - Fix on progress bar when uploading files [[68.4.0]](https://github.com/cozy/cozy-ui/releases/tag/v68.4.0)\n  - re-enable the Viewer's download button from cozy/cozy-ui#2234 [[74.4.0]](https://github.com/cozy/cozy-ui/releases/tag/v74.4.0)\n* Update cozy-scripts for Amirale development\n* Add visual feedback when uploading on a public view\n* Cache on clients request. Specially useful when the user didn't hide the \"Install Cozy Drive for desktop\" banner.\n\n## 🐛 Bug Fixes\n\n* Improve cozy-bar implementation to fix UI bugs in Amirale\n* Fix navigation through mobile Flagship on Note creation and opening\n* Remove unused contacts permissions on Photos\n* fix in photos: timeline query needs select fields to be completed\n\n## 🔧 Tech\n\n* Move dacc-run file to a lib folder to prevent it occurring in build\n* Shortcut links are now opened directly in the webview when executed inside the Flagship mobile app\n* fix: Viewer issue, make search backward compatible, add cache to the clients query\n\n# 1.42.1\n\n## 🐛 Bug Fixes\n\n* Fix services that were broken due to latest cozy-client update [[PR]](https://github.com/cozy/cozy-client/pull/1180)\n\n# 1.42.0\n\n## 🐛 Bug Fixes\n\n* Disable sharing on public file viewer\n\n## 🔧 Tech\n\n* Remove useless props to Viewer + useless Viewer footer/panel code\n\n# 1.41.0\n\n## ✨ Features\n\n* When displaying cozy-home from Cozy's native application, the Support Us is not displayed\n* Upgrade Cozy-Scripts to enable service-worker\n* Photos: Fix pagination issue\n* Change Sentry url\n* Display tiny thumbnail instead of small\n* Display thumbnail for PDF (behind a flag)\n* Support client-side encrypted files visualization\n* Disable unsuported items inside encrypted folder\n\n## 🐛 Bug Fixes\n\n * Compute sizes in MB instead of MiB in dacc service.\n * Query files based on their uploaded date in dacc service.\n * Do not query encryption files when flag is not set\n * Fix upload on shared folders\n\n## 🔧 Tech\n\n* Upgrade bundlemon to run on master pipeline and explicit delta on PR\n* Add pull request template, explicit CHANGELOG.md to update\n* Update several dependencies packages\n* Publish in our internal communication tool, when new versions of the applications are released\n* Update documentation about standalone mode and Transifex\n* Add script command to execute version update for drive and photos simultaneously\n* Clear mocks automatically in the configuration of Jest, our test runner\n* Minor improvements in the code revealed by our linters\n* Remove react-autosuggest as not used directly in this package\n* Remove react-tooltip as not used directly in this package\n* Upgrade eslint-cozy-config-app to use eslint@v7\n* Unregister any service worker that could have been registered during development\n* Improve a fragile test, breaking while some Node 16 pipeline\n* Add codeowners in the repository\n* Upgrade cozy-client for flagship app\n* Upgrade cozy-ui for matomo\n\n# 1.40.0\n\n## 🐛 Bug Fixes\n\n* Escape public name in public cozy-to-cozy sharing view\n* Fix upload when file name contains characters like `#` or `&`\n* Fix AppIcon issue\n\n## 🔧 Tech\n\n* Update several dependencies packages\n* Remove cozy-jobs-cli useless devDependencies packages\n* Remove piwik-react-router useless dependencies packages\n* Add date attribute to dacc flag\n* Add generic build command\n* Move [dependabot](https://github.com/dependabot) config file to correct location\n* Fix auto-merge job what disallowed used merge commits\n* Remove Drive Android job\n\n\n# 1.39.0\n\n## ✨ Features\n\n* Use MUI Breadcrumb with fully fetched path\n* Add feature flag on Breadcrumb on public view\n* Allow all users to see progress on upload file\n* DACC service to send anonymized measures about the file sizes grouped by app/konnector\n* Implement cozy-bar AA navigation\n* Log sentry exception on click on add menu when offline\n* Upgrade cozy-client to allow all users to see progress on upload file\n* Upgrade cozy-ui to benefit of new version of material-ui component\n\n## 🐛 Bug Fixes\n\n* Upgrade cozy-ui to make upload progress bar size fixed\n* Upload: return average remaining time each 3 seconds\n\n## 🔧 Tech\n\n* Format style files of the full repository to respect the Cozy Stylint config\n* Update several dependencies packages\n* Remove node-uuid unused package\n* Configure the bot [dependabot](https://github.com/dependabot) to commit according to our convention\n* Use only one syntax of data-testid\n\n# 1.38.0\n\n## ✨ Features\n\n* Filename is displayed in title when hovering the line.\n* Add multiple import at once for Android\n* Remove Pouch adapter migration\n\n## 🐛 Bug Fixes\n\n* Do not update files in parallel in the qualification migration service, as it might fail in nsjail for too many files\n* Fix MoveModal breadcrumb\n* Display reasons of incorrect file name (illegal characters, forbidden name)\n* Prevent errors during upload of file inside Dropzone\n* Handle better icon inside the searchbar\n* Upgrade cozy-client in order to fix albums page from photos\n\n## 🔧 Tech\n\n* Use `<SharingBannerPlugin />` and `useSharingInfos()` from `cozy-sharing` instead of internal components\n* Fixed an error in Search result when the result contained at least one Cozy Note\n* Update cordova to 8.1.2 and cordova-android to 9.1.0\n* Upgrade cozy-client, cozy-scanner caniuse-lite and fix tests\n* Upgrade cozy-sharing to fix typo in French\n* Add locales in gitignore\n* Explicit full path when importing cozy-ui component inside doc\n\n# 1.37.0\n\n## 🐛 Bug Fixes\n\n* Fixed an error on mobile that was preventing users to long tap in order to trigger multiple files selection\n* Fixed an error in directory tree names appearing under filenames where sometimes, the path appeared scrambled\n* Fixed an error where creating a directory sent two save actions instead of one\n* Added a missing loading status on delete confirm modal button\n* Fixed issues related to recent view not going where it should when navigating back and forth in directory paths\n\n## 🔧 Tech\n\n* Add CodeQL in order to scan the code 🚫\n* Add rel noopener on target blank link\n"
  },
  {
    "path": "CODEOWNERS",
    "content": "# General code owners\n*       @JF-Cozy @zatteo @rezk2ll @lethemanh @doubleface @lenhanphung\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "How to contribute to Cozy Drive?\n====================================\n\nThank you for your interest in contributing to Cozy! There are many ways to contribute, and we appreciate all of them.\n\n\nSecurity Issues\n---------------\n\nIf you discover a security issue, please bring it to our attention right away! Please **DO NOT** file a public issue, instead send your report privately to security AT cozycloud DOT cc.\n\nSecurity reports are greatly appreciated and we will publicly thank you for it. We currently do not offer a paid security bounty program, but are not ruling it out in the future.\n\n\nBug Reports\n-----------\n\nWhile bugs are unfortunate, they're a reality in software. We can't fix what we don't know about, so please report liberally. If you're not sure if something is a bug or not, feel free to file a bug anyway.\n\nOpening an issue is as easy as following [this link][issues] and filling out the fields. Here are some things you can write about your bug:\n\n- A short summary\n- What did you try, step by step?\n- What did you expect?\n- What did happen instead?\n- What is the version of the Cozy Drive?\n\n\nPull Requests\n-------------\n\nPlease keep in mind that:\n\n- Pull-Requests point to the `master` branch\n- You need to cover your code and feature by tests\n- You may add documentation in the `/docs` directory to explain your choices if needed\n- We recommend to use [task lists][checkbox] to explain steps / features in your Pull-Request description\n- you do _not_ need to build app to submit a PR\n- you should update the Transifex source locale file if you modify it for your feature needs (see [Localization section in README][localization])\n\n\n### Workflow\n\nPull requests are the primary mechanism we use to change Cozy. GitHub itself has some [great documentation][pr] on using the Pull Request feature. We use the _fork and pull_ model described there.\n\n#### Step 1: Fork\n\nFork the project on GitHub and [check out your copy locally][forking].\n\n```\n$ git clone github.com/cozy/cozy-drive.git\n$ cd cozy-drive\n$ git remote add fork git://github.com/yourusername/cozy-drive.git\n```\n\n#### Step 2: Branch\n\nCreate a branch and start hacking:\n\n```\n$ git checkout -b my-branch origin/master\n```\n\n#### Step 3: Code\n\nWell, we think you know how to do that. Just be sure to follow the coding guidelines from the community ([standard JS][stdjs], comment the code, etc).\n\n#### Step 4: Test\n\nDon't forget to add tests and be sure they are green:\n\n```\n$ cd cozy-drive\n$ npm run test\n```\n\n#### Step 5: Commit\n\nWriting [good commit messages][commitmsg] is important. A commit message should describe what changed and why.\n\n#### Step 6: Rebase\n\nUse `git rebase` (_not_ `git merge`) to sync your work from time to time.\n\n```\n$ git fetch origin\n$ git rebase origin/master my-branch\n```\n\n#### Step 7: Push\n\n```\n$ git push -u fork my-branch\n```\n\nGo to https://github.com/yourusername/cozy-drive and select your branch. Click the 'Pull Request' button and fill out the form.\n\nAlternatively, you can use [hub] to open the pull request from your terminal:\n\n```\n$ git pull-request -b master -m \"My PR message\" -o\n```\n\nPull requests are usually reviewed within a few days. If there are comments to address, apply your changes in a separate commit and push that to your branch. Post a comment in the pull request afterwards; GitHub doesn't send out notifications when you add commits.\n\n\nWriting documentation\n---------------------\n\nDocumentation improvements are very welcome. We try to keep a good documentation in the `/docs` folder. But, you know, we are developers, we can forget to document important stuff that look obvious to us. And documentation can always be improved.\n\n\nTranslations\n------------\n\nThe Cozy Drive is translated on a platform called [Transifex][tx]. [This tutorial][tx-start] can help you to learn how to make your first steps here. If you have any question, don't hesitate to ask us!\n\n\nCommunity\n---------\n\nYou can help us by making our community even more vibrant. For example, you can write a blog post, take some videos, answer the questions on [the forum][forum], organize new meetups, and speak about what you like in Cozy!\n\n\n\n[issues]: https://github.com/cozy/cozy-drive/issues/new\n[pr]: https://help.github.com/categories/collaborating-with-issues-and-pull-requests/\n[forking]: http://blog.campoy.cat/2014/03/github-and-go-forking-pull-requests-and.html\n[stdjs]: http://standardjs.com/\n[commitmsg]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html\n[localization]: https://github.com/cozy/cozy-drive/blob/master/README.md#localization\n[hub]: https://hub.github.com/\n[tx]: https://www.transifex.com/cozy/\n[tx-start]: https://help.transifex.com/en/articles/6248698-getting-started-as-a-translator\n[forum]: https://forum.cozy.io/\n"
  },
  {
    "path": "LICENSE",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <http://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<http://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "README.md",
    "content": "# Twake Drive\n\n<p align=\"center\">\n  <a href=\"https://github.com/linagora/twake-drive\">\n   <img src=\"./docs/twake-drive-banner.jpeg\" alt=\"banner\">\n   \n  </a>\n  <p align=\"center\">\n    <b align=\"center\">The open-source alternative to Google Drive.</b>\n    <br />\n    <a href=\"https://twake.app\"><strong>Learn more »</strong></a>\n    <br />\n    <br />\n    <a href=\"https://twake-drive.com\">Website</a>\n    |\n    <a href=\"https://github.com/linagora/twake-drive/issues\">Issues</a>\n  </p>\n</p>\n\n## About\n\n<img width=\"100%!\" alt=\"booking-screen\" src=\"./docs/cozy-drive.png\">\n\n## What's Drive?\n\nTwake Drive makes your file management easy. Main features are:\n\n- File tree\n- Files and folders upload.\n- Files and folders sharing (via URLs)\n- Files and folders search\n\n## Getting Started\n\n_:pushpin: Note:_ [Yarn] is the official Node package manager of Twake Drive. Don't hesitate to [install Yarn][yarn-install]\n\n### Install\n\nStarting the Drive app requires you to [setup a dev environment][setup].\n\nYou can then clone the app repository and install dependencies:\n\n```sh\n$ git clone https://github.com/linagora/twake-drive.git\n$ cd twake-drive\n$ yarn install\n```\n\n:pushpin: Don't forget to set the local node version indicated in the `.nvmrc` before doing a `yarn install`.\n\nTwake Drive use a standard set of _npm scripts_ to run common tasks, like watch, lint, test, build…\n\n### Run in dev mode\n\nUsing a watcher - with Hot Module Replacement:\n\n```sh\n$ cd twake-drive\n$ yarn watch\n$ cozy-stack serve --appdir drive:/<project_absolute_path>/twake-drive/build/drive --disable-csp\n```\n\nOr directly build the app (static file generated):\n\n```sh\n$ cd twake-drive\n$ yarn build\n$ cozy-stack serve --appdir drive:/<project_absolute_path>/twake-drive/build/drive\n```\n\nYour app is available at http://drive.cozy.localhost:8080/#/folder\n\nNote: it's mandatory to explicit to cozy-stack the folder of the build that should be served, to be able to run the app.\n\n### Run it inside the VM\n\nYou can view your current running app, you can use the [cozy-stack docker image][cozy-stack-docker]:\n\n```sh\n# in a terminal, run your app in watch mode\n$ cd twake-drive\n$ yarn watch\n```\n\n```sh\n# in another terminal, run the docker container\n$ docker run --rm -it -p 8080:8080 -v \"$(pwd)/build/drive\":/data/cozy-app/drive cozy/cozy-app-dev\n```\n\nYour app is available at http://drive.cozy.tools:8080.\n\n## Advanced case\n\n### Share and send mails in development\n\nTwake Drive let users [share documents from twake to twake](https://github.com/cozy/cozy-stack/blob/master/docs/sharing.md#cozy-to-cozy-sharing).\n\nMeet Alice and Bob.\nAlice wants to share a folder with Bob.\nAlice clicks on the share button and fills in the email input with Bob's email address.\nBob receives an email with a _« Accept the sharing »_ button.\nBob clicks on that button and is redirected to Alice's twake to enter his own twake url to link both twakes.\nBob sees Alice's shared folder in his own twake.\n\n🤔 But how could we do this scenario on binary cozy-stack development environment?\n\nIf you develop with the [cozy-stack CLI](https://github.com/cozy/cozy-stack/blob/master/docs/cli/cozy-stack.md), you have to run [MailHog](https://github.com/mailhog/MailHog) on your computer and tell `cozy-stack serve` where to find the mail server with some [options](https://github.com/cozy/cozy-stack/blob/master/docs/cli/cozy-stack_serve.md#options):\n\n```\n./cozy-stack serve --appdir drive:../twake-drive/build --mail-disable-tls --mail-port 1025\n```\n\n_This commands assumes you `git clone` [twake-drive](https://github.com/linagora/twake-drive) in the same folder than you `git clone` [cozy-stack](https://github.com/cozy/cozy-stack)._\n\nThen simply run `mailhog` and open http://cozy.tools:8025/.\n\n#### Retrieve sent emails\n\nWith MailHog, **every email** sent by cozy-stack is caught. That means the email address _does not have to be a real one_, ie. `bob@cozy`, `bob@cozy.tools` are perfectly fine. It _could be a real one_, but the email will not reach the real recipient's inbox, say `contact@cozycloud.cc`.\n\n### Living on the edge\n\n[Cozy-ui] is our frontend stack library that provides common styles and components accross the whole Twake React apps. You can use it for you own application to follow the official Twake's guidelines and styles. If you need to develop / hack cozy-ui, it's sometimes more useful to develop on it through another app. You can do it by cloning cozy-ui locally and link it to yarn local index:\n\n```sh\ngit clone https://github.com/cozy/cozy-ui.git\ncd cozy-ui\nyarn install\nyarn link\n```\n\nthen go back to your app project and replace the distributed cozy-ui module with the linked one:\n\n```sh\ncd twake-drive\nyarn link cozy-ui\n```\n\nYou can now run the watch task and your project will hot-reload each times a cozy-ui source file is touched.\n\n###### Troubleshooting\n\nConsider using [rlink] instead of `yarn link`\n\n[Cozy-client] is our API library that provides an unified API on top of the cozy-stack. If you need to develop / hack cozy-client in parallel of your application, you can use the same trick that we used with [cozy-ui]: yarn linking.\n\n### Tests\n\nTests are run by [jest] under the hood, and written using [chai] and [sinon]. You can easily run the tests suite with:\n\n```sh\n$ cd twake-drive\n$ yarn test\n```\n\n:pushpin: Don't forget to update / create new tests when you contribute to code to keep the app the consistent.\n\n### Open a Pull-Request\n\nIf you want to work on Drive and submit code modifications, feel free to open pull-requests! See the [contributing guide][contribute] for more information about how to properly open pull-requests.\n\n## Community\n\n### Localization\n\nLocalization and translations are handled by [Transifex][tx].\n\nAs a _translator_, you can login to [Transifex][tx-signin] (using your Github account) and claim access to the [app repository][tx-app]. Locales are pulled [by the pipeline][yarn tx in travis.yml] when app is build before publishing.\n\nAs a _developer_, you must configure the [Transifex CLI][tx-cli], and claim access as _maintainer_ to the [app repository][tx-app]. Then please **only update** the source locale file (usually `en.json` in client and/or server parts), and push it to Transifex repository using the `tx push -s` command.\n\nIf you were using a [transifex-client](tx-client), you must move to [Transifex CLI](tx-cli) to be compatible with the v3 API.\n\nThe transifex configuration file is still in an old version. Please use the previous client for the moment [https://github.com/transifex/transifex-client/](https://github.com/transifex/transifex-client/).\n\n## License\n\nTwake Drive is developed by Linagora and distributed under the [AGPL v3 license][agpl-3.0].\n\n[cozy]: https://cozy.io 'Cozy Cloud'\n[setup]: https://docs.cozy.io/en/tutorials/app/#install-the-development-environment 'Cozy dev docs: Set up the Development Environment'\n[yarn]: https://yarnpkg.com/\n[yarn-install]: https://yarnpkg.com/en/docs/install\n[cozy-ui]: https://github.com/cozy/cozy-ui\n[rlink]: https://gist.github.com/ptbrowne/add609bdcf4396d32072acc4674fff23\n[cozy-client]: https://github.com/cozy/cozy-client/\n[cozy-stack-docker]: https://github.com/cozy/cozy-stack/blob/master/docs/client-app-dev.md#with-docker\n[doctypes]: https://cozy.github.io/cozy-doctypes/\n[bill-doctype]: https://github.com/cozy/cozy-konnector-libs/blob/master/models/bill.js\n[konnector-doctype]: https://github.com/cozy/cozy-konnector-libs/blob/master/models/base_model.js\n[konnectors]: https://github.com/cozy/cozy-konnector-libs\n[agpl-3.0]: https://www.gnu.org/licenses/agpl-3.0.html\n[contribute]: CONTRIBUTING.md\n[tx]: https://www.transifex.com/cozy/\n[tx-signin]: https://www.transifex.com/signin/\n[tx-app]: https://www.transifex.com/cozy/cozy-drive/dashboard/\n[tx-translate]: https://www.transifex.com/cozy/cozy-drive/translate/\n[tx-cli]: https://developers.transifex.com/docs/cli\n[tx-client]: https://github.com/transifex/transifex-client\n[libera]: https://web.libera.chat/#cozycloud\n[forum]: https://forum.cozy.io/\n[github]: https://github.com/cozy/\n[twitter]: https://twitter.com/linagora\n[nvm]: https://github.com/creationix/nvm\n[cozy-dev]: https://github.com/cozy/cozy-dev/\n[jest]: https://jestjs.io/fr/\n[chai]: http://chaijs.com/\n[sinon]: http://sinonjs.org/\n[checkbox]: https://help.github.com/articles/basic-writing-and-formatting-syntax/#task-lists\n[yarn tx in travis.yml]: .travis.yml#L41\n"
  },
  {
    "path": "babel.config.js",
    "content": "module.exports = {\n  presets: ['cozy-app', '@babel/env']\n}\n"
  },
  {
    "path": "docs/nextcloud.md",
    "content": "# Nextcloud\n\nThe integration of Nextcloud within cozy-drive relies heavily on cozy-client and the proxy made by cozy-stack ([doc](https://docs.cozy.io/en/cozy-stack/nextcloud/)). This implies 2 main constraints that are worth mentioning if you want to understand the code better.\n\n**1. Obtain a folder itself**\n\nThe query to `io.cozy.remote.nextcloud.files` can only retrieve the contents of one folder. To avoid this problem, we query its parent and filter by id to get data about.\n\n**2. Reload data after mutation**\n\nThe mutations doesn't have any effect on the local store because we cannot update it with the request's response like for `io.cozy.files` and there are no real-time event that would update. To avoid this problem, we reset the affected queries with `client.reset(queryId)`.\n\nWhen the query involves moving a file, the destination query is privileged. The cache of the source query will be updated when cozy-client receives the reset query answer. This avoids an additional network request. If the query does not exist, then we reset the source query.\n"
  },
  {
    "path": "eslint.config.mjs",
    "content": "import basics from 'eslint-config-cozy-app/basics'\nimport cozyReact from 'eslint-config-cozy-app/react'\n\nconst baseImportOrderRule = basics.find(c => c.rules?.['import/order'])?.rules[\n  'import/order'\n]\nconst baseImportOrderOptions = baseImportOrderRule[1]\nconst basePathGroups = baseImportOrderOptions.pathGroups\n\nexport default [\n  ...cozyReact,\n  {\n    rules: {\n      'import/order': [\n        'warn',\n        {\n          ...baseImportOrderOptions,\n          pathGroups: [\n            ...basePathGroups,\n            { pattern: '**/*.styl', group: 'index', position: 'after' },\n            { pattern: 'test/**/*', group: 'index' },\n            { pattern: 'lib/**/*', group: 'index' },\n            { pattern: 'hooks/**/*', group: 'index' },\n            { pattern: 'components/**/*', group: 'index' },\n            { pattern: 'modules/**/*', group: 'index' },\n            { pattern: 'assets/**/*', group: 'index' },\n            { pattern: 'models/**/*', group: 'index' },\n            { pattern: 'config/**/*', group: 'index' },\n            { pattern: 'constants/**/*', group: 'index' },\n            { pattern: 'locales/**/*', group: 'index' },\n            { pattern: 'queries', group: 'index' }\n          ]\n        }\n      ]\n    }\n  }\n]\n"
  },
  {
    "path": "jest.config.js",
    "content": "module.exports = {\n  roots: ['<rootDir>/src'],\n  setupFiles: ['<rootDir>/jestHelpers/setup.js'],\n  setupFilesAfterEnv: ['<rootDir>/jestHelpers/setupFilesAfterEnv.js'],\n  moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'styl'],\n  moduleNameMapper: {\n    '.(png|gif|jpe?g)$': '<rootDir>/jestHelpers/mocks/fileMock.js',\n    '.svg$': '<rootDir>/jestHelpers/mocks/iconMock.js',\n    '\\\\?raw$': '<rootDir>/jestHelpers/mocks/svgRawMock.js',\n    '.styl$': 'identity-obj-proxy',\n    '\\\\.(css|less)$': 'identity-obj-proxy',\n    '^locales/.*': '<rootDir>/src/locales/en.json',\n    '^models(.*)': '<rootDir>/src/models$1',\n    '^sharing(.*)': '<rootDir>/src/sharing$1',\n    '^authentication(.*)': '<rootDir>/src/authentication$1',\n    '^viewer(.*)': '<rootDir>/src/viewer$1',\n    '^react-cozy-helpers(.*)': '<rootDir>/src/lib/react-cozy-helpers$1',\n    '^components(.*)': '<rootDir>/src/components$1',\n    '^hooks(.*)': '<rootDir>/src/hooks$1',\n    '^test(.*)': '<rootDir>/test/$1',\n    '^lib(.*)': '<rootDir>/src/lib$1',\n    'react-pdf/dist/esm/pdf.worker.entry':\n      '<rootDir>/jestHelpers/mocks/pdfjsWorkerMock.js',\n    '^cozy-client$': 'cozy-client/dist/index.js',\n    '^react-redux': '<rootDir>/node_modules/react-redux',\n    '^cozy-ui/react(.*)$': '<rootDir>/node_modules/cozy-ui/transpiled/react$1',\n    '^config/(.*)': '<rootDir>/src/config/$1',\n    '^constants/(.*)': '<rootDir>/src/constants/$1',\n    '^modules/(.*)': '<rootDir>/src/modules/$1',\n    '^queries(.*)': '<rootDir>/src/queries$1',\n    '^@/(.*)$': '<rootDir>/src/$1'\n  },\n  clearMocks: true,\n  transform: {\n    '\\\\.(js|jsx|mjs)$': [\n      '@swc/jest',\n      {\n        jsc: {\n          experimental: {\n            plugins: [['@swc-contrib/mut-cjs-exports', {}]]\n          },\n          parser: {\n            jsx: true\n          }\n        }\n      }\n    ],\n    '\\\\.(ts|tsx)$': [\n      '@swc/jest',\n      {\n        jsc: {\n          experimental: {\n            plugins: [['@swc-contrib/mut-cjs-exports', {}]]\n          },\n          parser: {\n            syntax: 'typescript',\n            tsx: true\n          }\n        }\n      }\n    ],\n    '^.+\\\\.webapp$': '<rootDir>/test/jestLib/json-transformer.js'\n  },\n  transformIgnorePatterns: [\n    'node_modules/(?!cozy-ui|cozy-harvest-lib|cozy-keys-lib|cozy-sharing|)',\n    'jest-runner'\n  ],\n  testEnvironment: 'jsdom',\n  testEnvironmentOptions: {\n    url: 'http://cozy.localhost:8080/'\n  },\n  testMatch: ['**/(*.)(spec|test).[jt]s?(x)'],\n  reporters: ['default', '<rootDir>/jestHelpers/ConsoleUsageReporter.js']\n}\n"
  },
  {
    "path": "jestHelpers/ConsoleUsageReporter.js",
    "content": "/* eslint-disable class-methods-use-this */\nconst fs = require('fs')\nconst path = require('path')\n\nconst { red, reset } = require('chalk')\n\nconst TMP_FILE_PATH = path.join(process.cwd(), '.consoleUsageReporter.json')\n\n/**\n * Prevents using the console in the tests without mocking it and prevents not\n * handling errors/warnings from 3rd parties.\n */\nmodule.exports = class ConsoleUsageReporter {\n  static deleteTemporaryFile() {\n    try {\n      fs.unlinkSync(TMP_FILE_PATH)\n    } catch (e) {\n      // Ignored\n    }\n  }\n\n  static getTestFilesThatUsedConsole() {\n    try {\n      return JSON.parse(fs.readFileSync(TMP_FILE_PATH, 'utf8'))\n    } catch (e) {\n      return []\n    }\n  }\n\n  static recordConsoleUsedInCurrentTestFile() {\n    const testPath =\n      global.jasmine?.testPath || expect.getState()?.testPath || ''\n    const testFilesThatUsedConsole = this.getTestFilesThatUsedConsole()\n\n    if (!testFilesThatUsedConsole.includes(testPath)) {\n      testFilesThatUsedConsole.push(testPath)\n      fs.writeFileSync(\n        TMP_FILE_PATH,\n        JSON.stringify(testFilesThatUsedConsole),\n        'utf8'\n      )\n    }\n  }\n\n  static makeTestsFailWhenConsoleUsed() {\n    let consoleCalls = []\n    let testsRunning = true\n\n    const formatConsoleCalls = calls =>\n      calls\n        .map(({ args, callStack, method }) => {\n          const formattedArgs = args\n            .map(arg => (arg instanceof Error ? arg.stack || arg : arg))\n            .join(' ')\n            .split('\\n')\n            .map(line => `  ${line}`)\n            .join('\\n')\n\n          const formattedCallStack = !/^\\s*(at|in) /m.test(formattedArgs)\n            ? red(\n                `\\n\\n${callStack\n                  .split('\\n')\n                  .map(line => `  ${line}`)\n                  .join('\\n')}`\n              )\n            : ''\n\n          return `console.${method}\\n${reset(\n            formattedArgs\n          )}${formattedCallStack}`\n        })\n        .join('\\n\\n')\n    ;['error', 'info', 'log', 'warn'].forEach(method => {\n      global.console[method] = (...args) => {\n        const callStack = new Error().stack\n          .split('\\n')\n          .slice(2)\n          .map(line => line.trim())\n          .join('\\n')\n\n        if (consoleCalls.length === 0) {\n          ConsoleUsageReporter.recordConsoleUsedInCurrentTestFile()\n        }\n\n        if (testsRunning) {\n          consoleCalls.push({ args, callStack, method })\n        } else {\n          process.stderr.write(\n            red(`\nThe console has been called outside a test which usually means you mishandled asynchronous actions.\n\nHere is what have been logged:\n\n${reset(formatConsoleCalls([{ args, callStack, method }]))}\n`)\n          )\n        }\n      }\n    })\n\n    beforeAll(() => {\n      testsRunning = true\n    })\n\n    beforeEach(() => {\n      consoleCalls = []\n    })\n\n    afterEach(() => {\n      if (consoleCalls.length > 0) {\n        throw new Error(\n          red(`\\\nThis test called the console which is forbidden.\n\nHere is what have been logged:\n\n${reset(formatConsoleCalls(consoleCalls))}\n\nIf calling the console is normal in your test case, consider mocking the \\\nconsole as is:\n\n  jest.spyOn(console, 'method').mockImplementation();\n`)\n        )\n      }\n    })\n\n    afterAll(() => {\n      testsRunning = false\n    })\n  }\n\n  constructor(globalConfig) {\n    this.globalConfig = globalConfig\n  }\n\n  onRunComplete() {\n    const isWatchModeEnabled =\n      this.globalConfig.watch || this.globalConfig.watchAll\n    const testFilesThatUsedConsole =\n      ConsoleUsageReporter.getTestFilesThatUsedConsole()\n\n    ConsoleUsageReporter.deleteTemporaryFile()\n\n    if (testFilesThatUsedConsole.length > 0) {\n      const error = new Error(\n        red(\n          `\\\nThe following test files called the console which is forbidden:\n${testFilesThatUsedConsole.map(file => `- ${file}`).join('\\n')}\n\nYou should find more information in the report of the test that called the \\\nconsole.\n\nWe list all the test files there to allow you to find the console calls that \\\ndid not make any test fail (possibly because of async issues).\n`\n        )\n      )\n\n      if (isWatchModeEnabled) {\n        // Prevents to freeze watch mode\n        console.error(error)\n      } else {\n        throw error\n      }\n    }\n  }\n\n  onRunStart() {\n    ConsoleUsageReporter.deleteTemporaryFile()\n  }\n}\n"
  },
  {
    "path": "jestHelpers/mocks/fileMock.js",
    "content": "module.exports = {};\n"
  },
  {
    "path": "jestHelpers/mocks/iconMock.js",
    "content": "let id = 0;\nmodule.exports = { id: `icon-${id++}` };\n"
  },
  {
    "path": "jestHelpers/mocks/pdfjsWorkerMock.js",
    "content": "module.exports = () => {};\n"
  },
  {
    "path": "jestHelpers/mocks/svgRawMock.js",
    "content": "module.exports = '<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"></svg>'\n\n\n"
  },
  {
    "path": "jestHelpers/setup.js",
    "content": "import React from 'react'\nimport { TransformStream } from 'stream/web'\n\nglobal.cozy = {}\nglobal.TransformStream = TransformStream\n\njest.mock('cozy-search', () => ({\n  AssistantDesktop: () => null,\n  AssistantDialog: () => null,\n  SearchDialog: () => null\n}))\n\njest.mock('cozy-bar', () => ({\n  ...jest.requireActual('cozy-bar'),\n  BarComponent: () => <div>Bar</div>,\n  BarLeft: ({ children }) => children,\n  BarRight: ({ children }) => children,\n  BarCenter: ({ children }) => children,\n  BarSearch: ({ children }) => children\n}))\n\njest.mock('cozy-intent', () => ({\n  useWebviewIntent: jest.fn()\n}))\n\njest.mock('cozy-dataproxy-lib', () => ({\n  DataProxyProvider: ({ children }) => children\n}))\n\n// Mock cozy-flags with jest mock function that supports both flag checking and test mocking\njest.mock('cozy-flags', () => {\n  const mockFn = jest.fn(() => {\n    // Return false for all other flags to avoid issues\n    return false\n  })\n\n  // Add initialize method that some tests expect\n  mockFn.initialize = jest.fn()\n\n  return mockFn\n})\n\n// see https://github.com/jsdom/jsdom/issues/1695\nwindow.HTMLElement.prototype.scroll = function () {}\n"
  },
  {
    "path": "jestHelpers/setupFilesAfterEnv.js",
    "content": "import '@testing-library/jest-dom'\nimport ConsoleUsageReporter from './ConsoleUsageReporter'\n\nConsoleUsageReporter.makeTestsFailWhenConsoleUsed()\n\nprocess.on('unhandledRejection', error => console.error(error))\n"
  },
  {
    "path": "manifest.webapp",
    "content": "{\n  \"name\": \"Drive\",\n  \"name_prefix\": \"Twake\",\n  \"slug\": \"drive\",\n  \"version\": \"1.99.0\",\n  \"type\": \"webapp\",\n  \"licence\": \"AGPL-3.0\",\n  \"icon\": \"assets/app-icon.svg\",\n  \"categories\": [\"cozy\"],\n  \"source\": \"https://github.com/cozy/cozy-drive\",\n  \"editor\": \"Cozy\",\n  \"developer\": {\n   \"name\": \"Twake Workplace\",\n    \"url\": \"https://twake.app\"\n  },\n  \"locales\": {\n    \"en\": {\n      \"short_description\": \"Twake Drive helps you to save, sync and secure your files on your Twake.\",\n      \"long_description\": \"With Twake Drive, you can easily:\\n- Store your important files and keep them secure in your Twake\\n- Access to all your documents online & offline, from your desktop, and on your smartphone or tablet\\n- Share links to files ans folders with who you like;\\n- Automatically retrieve bills, payrolls, tax notices and other data from your main online services (internet, energy, retail, mobile, energy, travel...)\\n- Upload files to your Twake from your Android\",\n      \"screenshots\": [\n        \"assets/screenshots/en/screenshot01.png\",\n        \"assets/screenshots/en/screenshot02.png\",\n        \"assets/screenshots/en/screenshot03.png\",\n        \"assets/screenshots/en/screenshot04.png\"\n      ]\n    },\n    \"fr\": {\n      \"short_description\": \"Twake Drive est l’application de sauvegarde, de synchronisation et de sécurisation de tous vos fichiers sur Twake.\",\n      \"long_description\": \"Avec Twake Drive vous pourrez :\\n- Sauvegarder et synchroniser gratuitement tous vos documents importants (carte d’identité, photos de vacances, avis d’imposition, fiches de salaires…);\\n- Accéder à vos documents n’importe quand, n’importe ou même en mode avion depuis votre bureau, votre smartphone ou tablette;\\n- Partager vos fichiers et dossiers par lien avec qui vous le souhaitez;\\n- Récupérer automatiquement vos documents administratifs de vos principaux fournisseurs de service (opérateur mobile, fournisseur d’énergie, assureur, internet, santé…);\\n- Rester synchronisé·e lors de vos voyages et déplacements professionnels avec nos applications mobiles.\",\n      \"screenshots\": [\n        \"assets/screenshots/fr/screenshot01.png\",\n        \"assets/screenshots/fr/screenshot02.png\",\n        \"assets/screenshots/fr/screenshot03.png\",\n        \"assets/screenshots/fr/screenshot04.png\"\n      ]\n    }\n  },\n  \"screenshots\": [\n    \"assets/screenshots/fr/screenshot01.png\",\n    \"assets/screenshots/fr/screenshot02.png\",\n    \"assets/screenshots/fr/screenshot03.png\",\n    \"assets/screenshots/fr/screenshot04.png\"\n  ],\n  \"langs\": [\"en\", \"fr\"],\n  \"routes\": {\n    \"/\": {\n      \"folder\": \"/\",\n      \"index\": \"index.html\",\n      \"public\": false\n    },\n    \"/intents\": {\n      \"folder\": \"/intents\",\n      \"index\": \"index.html\",\n      \"public\": false\n    },\n    \"/public\": {\n      \"folder\": \"/public\",\n      \"index\": \"index.html\",\n      \"public\": true\n    },\n    \"/preview\": {\n      \"folder\": \"/public\",\n      \"index\": \"index.html\",\n      \"public\": true\n    },\n    \"/assets\": {\n      \"folder\": \"/assets\",\n      \"public\": true\n    }\n  },\n  \"intents\": [\n    {\n      \"action\": \"OPEN\",\n      \"type\": [\"io.cozy.files\"],\n      \"href\": \"/intents\"\n    },\n    {\n      \"action\": \"OPEN\",\n      \"type\": [\"io.cozy.suggestions\"],\n      \"href\": \"/intents\"\n    }\n  ],\n  \"entrypoints\": [\n    {\n      \"name\": \"new-file-type-text\",\n      \"title\": {\n        \"en\": \"Doc\",\n        \"fr\": \"Doc\",\n        \"ru\": \"документ\",\n        \"vi\": \"Doc\"\n      },\n      \"hash\": \"/onlyoffice/create/io.cozy.files.root-dir/text\",\n      \"icon\": \"PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzAwNzZFRCIgZD0iTTguNCAwQTYuNCA2LjQgMCAwIDAgMiA2LjR2MTkuMkE2LjQgNi40IDAgMCAwIDguNCAzMmgxNmE2LjQgNi40IDAgMCAwIDYuNC02LjRWMTAuNEwyMC40IDBjLTQuMDEzIDAtNCAwIDAgMGgtMTJaIi8+PHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjIuOTA5IiB4PSI4IiB5PSIxMCIgZmlsbD0iI2ZmZiIgcng9IjEuNDU1Ii8+PHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjIuOTA5IiB4PSI4IiB5PSIxNS45MDkiIGZpbGw9IiNmZmYiIHJ4PSIxLjQ1NSIvPjxyZWN0IHdpZHRoPSIxNiIgaGVpZ2h0PSIyLjkwOSIgeD0iOCIgeT0iMjEuODE4IiBmaWxsPSIjZmZmIiByeD0iMS40NTUiLz48cGF0aCBmaWxsPSIjMDA2OEQyIiBkPSJNMzAuOCAxMC44IDIwIDB2NS41ODZjMCAyLjg4IDIuMjU3IDUuMjE0IDUuMDQgNS4yMTRoNS43NloiLz48L3N2Zz4=\",\n      \"conditions\": [{ \"type\": \"flag\", \"name\": \"drive.office.enabled\", \"value\": true }, { \"type\": \"flag\", \"name\": \"drive.office.write\", \"value\": true }, { \"type\": \"flag\", \"name\": \"bar.onlyoffice.enabled\", \"value\": true }]\n    },\n    {\n      \"name\": \"new-file-type-sheet\",\n      \"title\": {\n        \"en\": \"Spreadsheet\",\n        \"fr\": \"Tableur\",\n        \"ru\": \"Электронная таблица\",\n        \"vi\": \"Bảng tính\"\n      },\n      \"hash\": \"/onlyoffice/create/io.cozy.files.root-dir/spreadsheet\",\n      \"icon\": \"PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iIzBEOUUzMCIgZD0iTTguNCAwQTYuNCA2LjQgMCAwIDAgMiA2LjR2MTkuMkE2LjQgNi40IDAgMCAwIDguNCAzMmgxNmE2LjQgNi40IDAgMCAwIDYuNC02LjRWMTAuNEwyMC40IDBjLTQuMDEzIDAtNCAwIDAgMGgtMTJaIi8+PHJlY3Qgd2lkdGg9IjYuODU3IiBoZWlnaHQ9IjYuODU3IiB4PSI4IiB5PSIxMCIgZmlsbD0iI2ZmZiIgcng9Ii40NTciLz48cmVjdCB3aWR0aD0iNi44NTciIGhlaWdodD0iNi44NTciIHg9IjgiIHk9IjE5LjE0MyIgZmlsbD0iI2ZmZiIgcng9Ii40NTciLz48cmVjdCB3aWR0aD0iNi44NTciIGhlaWdodD0iNi44NTciIHg9IjE3LjE0MyIgeT0iMTkuMTQzIiBmaWxsPSIjZmZmIiByeD0iLjQ1NyIvPjxyZWN0IHdpZHRoPSI2Ljg1NyIgaGVpZ2h0PSI2Ljg1NyIgeD0iMTcuMTQzIiB5PSIxMCIgZmlsbD0iI2ZmZiIgcng9Ii40NTciLz48cGF0aCBmaWxsPSIjMDA4NDIwIiBkPSJNMzAuOCAxMC44IDIwIDB2NS41ODZjMCAyLjg4IDIuMjU3IDUuMjE0IDUuMDQgNS4yMTRoNS43NloiLz48L3N2Zz4=\",\n      \"conditions\": [{ \"type\": \"flag\", \"name\": \"drive.office.enabled\", \"value\": true }, { \"type\": \"flag\", \"name\": \"drive.office.write\", \"value\": true }, { \"type\": \"flag\", \"name\": \"bar.onlyoffice.enabled\", \"value\": true }]\n    },\n    {\n      \"name\": \"new-file-type-slide\",\n      \"title\": {\n        \"fr\": \"Présentation\",\n        \"en\": \"Presentation\",\n        \"ru\": \"Презентация\",\n        \"vi\": \"Giới thiệu\"\n      },\n      \"hash\": \"/onlyoffice/create/io.cozy.files.root-dir/slide\",\n      \"icon\": \"PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iI0ZGOTUwMCIgZD0iTTguNCAwQTYuNCA2LjQgMCAwIDAgMiA2LjR2MTkuMkE2LjQgNi40IDAgMCAwIDguNCAzMmgxNmE2LjQgNi40IDAgMCAwIDYuNC02LjRWMTAuNEwyMC40IDBjLTQuMDEzIDAtNCAwIDAgMGgtMTJaIi8+PHBhdGggZmlsbD0iI0RGNjMxMCIgZD0iTTMwLjggMTAuOCAyMCAwdjUuNTg2YzAgMi44OCAyLjI1NyA1LjIxNCA1LjA0IDUuMjE0aDUuNzZaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE1LjQyMiAxMS4xNTdhNy40MjIgNy40MjIgMCAxIDAgNy40MjEgNy40MjFoLTcuNDIxdi03LjQyMVoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTYuNTc4IDEwdjcuNDIySDI0QTcuNDIyIDcuNDIyIDAgMCAwIDE2LjU3OCAxMFoiLz48L3N2Zz4=\",\n      \"conditions\": [{ \"type\": \"flag\", \"name\": \"drive.office.enabled\", \"value\": true }, { \"type\": \"flag\", \"name\": \"drive.office.write\", \"value\": true }, { \"type\": \"flag\", \"name\": \"bar.onlyoffice.enabled\", \"value\": true }]\n    }\n  ],\n  \"services\": {\n    \"qualificationMigration\": {\n      \"type\": \"node\",\n      \"file\": \"services/qualificationMigration/drive.js\"\n    },\n    \"dacc\": {\n      \"type\": \"node\",\n      \"file\": \"services/dacc/drive.js\",\n      \"trigger\": \"@monthly on the 5-7 between 2pm and 7pm\"\n    }\n  },\n  \"permissions\": {\n    \"files\": {\n      \"description\": \"Required to access the files\",\n      \"type\": \"io.cozy.files\",\n      \"verbs\": [\"ALL\"]\n    },\n    \"allFiles\": {\n      \"description\": \"Required to access the files\",\n      \"type\": \"io.cozy.files.*\",\n      \"verbs\": [\"ALL\"]\n    },\n    \"apps\": {\n      \"description\": \"Required by the cozy-bar to display the icons of the apps\",\n      \"type\": \"io.cozy.apps\",\n      \"verbs\": [\"GET\"]\n    },\n    \"sharings\": {\n      \"description\": \"Required to have access to the sharings in realtime\",\n      \"type\": \"io.cozy.sharings\",\n      \"verbs\": [\"GET\"]\n    },\n    \"albums\": {\n      \"description\": \"Required to manage photos albums\",\n      \"type\": \"io.cozy.photos.albums\",\n      \"verbs\": [\"PUT\", \"GET\"]\n    },\n    \"contacts\": {\n      \"type\": \"io.cozy.contacts\",\n      \"verbs\": [\"GET\", \"POST\"]\n    },\n    \"groups\": {\n      \"type\": \"io.cozy.contacts.groups\",\n      \"verbs\": [\"GET\"]\n    },\n    \"settings\": {\n      \"description\": \"Required by the cozy-bar to display Claudy and know which applications are coming soon\",\n      \"type\": \"io.cozy.settings\",\n      \"verbs\": [\"GET\"]\n    },\n    \"oauth\": {\n      \"description\": \"Required to display the cozy-desktop banner\",\n      \"type\": \"io.cozy.oauth.clients\",\n      \"verbs\": [\"GET\"]\n    },\n    \"errorsreporting\": {\n      \"description\": \"Allow to report unexpected errors to the support team\",\n      \"type\": \"cc.cozycloud.errors\",\n      \"verbs\": [\"POST\"]\n    },\n    \"mail\": {\n      \"description\": \"Send feedback emails to the support team\",\n      \"type\": \"io.cozy.jobs\",\n      \"verbs\": [\"POST\"],\n      \"selector\": \"worker\",\n      \"values\": [\"sendmail\"]\n    },\n    \"konnectors\": {\n      \"description\": \"Required to display additional information in the viewer for files automatically retrieved by services\",\n      \"type\": \"io.cozy.konnectors\",\n      \"verbs\": [\"GET\"]\n    },\n    \"accounts\": {\n      \"description\": \"Required to display additional information in the viewer for files automatically retrieved by services\",\n      \"type\": \"io.cozy.accounts\",\n      \"verbs\": [\"ALL\"]\n    },\n    \"jobs\": {\n      \"type\": \"io.cozy.jobs\",\n      \"verbs\": [\"ALL\"]\n    },\n    \"triggers\": {\n      \"description\": \"Required to display additional information in the viewer for files automatically retrieved by services\",\n      \"type\": \"io.cozy.triggers\",\n      \"verbs\": [\"ALL\"]\n    },\n    \"dacc\": {\n      \"type\": \"cc.cozycloud.dacc_v2\",\n      \"verbs\": [\"POST\"],\n      \"description\": \"Remote-doctype required to send anonymized measures to the DACC shared among mycozy.cloud's Cozy.\"\n    },\n    \"dacc-eu\": {\n      \"type\": \"eu.mycozy.dacc_v2\",\n      \"verbs\": [\"POST\"],\n      \"description\": \"Remote-doctype required to send anonymized measures to the DACC shared among mycozy.eu's Cozy.\"\n    },\n    \"chatConversations\": {\n      \"description\": \"Required by the cozy Assistant\",\n      \"type\": \"io.cozy.ai.chat.conversations\",\n      \"verbs\": [\"GET\", \"POST\"]\n    },\n    \"chatEvents\": {\n      \"description\": \"Required by the cozy Assistant\",\n      \"type\": \"io.cozy.ai.chat.events\",\n      \"verbs\": [\"GET\"]\n    },\n    \"driveSettings\": {\n      \"description\": \"Required to access the drive settings\",\n      \"type\": \"io.cozy.drive.settings\",\n      \"verbs\": [\"ALL\"]\n    },\n    \"nextcloud_migrations\": {\n      \"description\": \"Read Nextcloud migration documents and subscribe to updates\",\n      \"type\": \"io.cozy.nextcloud.migrations\",\n      \"verbs\": [\"GET\"]\n    }\n  },\n  \"accept_from_flagship\": true,\n  \"accept_documents_from_flagship\": {\n    \"accepted_mime_types\": [\"*/*\"],\n    \"max_number_of_files\": 10,\n    \"max_size_per_file_in_MB\": 100,\n    \"route_to_upload\": \"/#/upload?fromFlagshipUpload=true\"\n  }\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"cozy-drive\",\n  \"version\": \"1.99.0\",\n  \"main\": \"src/main.jsx\",\n  \"scripts\": {\n    \"build\": \"rsbuild build\",\n    \"watch\": \"rsbuild build --watch --mode development\",\n    \"start\": \"rsbuild dev\",\n    \"analyze\": \"RSDOCTOR=true yarn build\",\n    \"cozyPublish\": \"cozy-app-publish --token $REGISTRY_TOKEN --prepublish downcloud --postpublish mattermost\",\n    \"tx\": \"tx pull --all || true\",\n    \"lint\": \"npm-run-all --parallel 'lint:*'\",\n    \"lint:styles\": \"stylint src --config ./node_modules/stylus-config-cozy-app/.stylintrc\",\n    \"lint:js\": \"eslint '{src,test}/**/*.{js,jsx,ts,tsx}'\",\n    \"test\": \"env NODE_ENV='test' jest\",\n    \"service\": \"yarn cozy-konnector-dev -m ./manifest.webapp\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/cozy/cozy-drive.git\"\n  },\n  \"author\": \"Cozy Cloud <contact@cozycloud.cc> (https://cozy.io/)\",\n  \"contributors\": [\n    \"CPatchane\",\n    \"enguerran\",\n    \"GoOz\",\n    \"goldoraf\",\n    \"gregorylegarec\",\n    \"kossi\",\n    \"m4dz\",\n    \"nono\",\n    \"ptbrowne\",\n    \"y_lohse\",\n    \"trollepierre\"\n  ],\n  \"license\": \"AGPL-3.0\",\n  \"bugs\": {\n    \"url\": \"https://github.com/cozy/cozy-drive/issues\"\n  },\n  \"homepage\": \"https://github.com/cozy/cozy-drive#readme\",\n  \"devDependencies\": {\n    \"@rsbuild/core\": \"^1.5.15\",\n    \"@swc-contrib/mut-cjs-exports\": \"^14.7.0\",\n    \"@swc/core\": \"^1.15.18\",\n    \"@swc/jest\": \"^0.2.39\",\n    \"@testing-library/jest-dom\": \"5.17.0\",\n    \"@testing-library/react\": \"14.3.1\",\n    \"@types/react-redux\": \"7.1.26\",\n    \"@typescript-eslint/eslint-plugin\": \"5.62.0\",\n    \"@typescript-eslint/parser\": \"5.62.0\",\n    \"@welldone-software/why-did-you-render\": \"^10.0.1\",\n    \"babel-preset-cozy-app\": \"2.1.0\",\n    \"bundlemon\": \"3.1.0\",\n    \"cozy-app-publish\": \"^0.40.1\",\n    \"cozy-jobs-cli\": \"^2.4.3\",\n    \"cozy-tsconfig\": \"^1.8.1\",\n    \"css-mediaquery\": \"0.1.2\",\n    \"eslint\": \"10.0.2\",\n    \"eslint-config-cozy-app\": \"7.0.0\",\n    \"husky\": \"0.14.3\",\n    \"identity-obj-proxy\": \"3.0.0\",\n    \"jest\": \"^30.0.0\",\n    \"jest-environment-jsdom\": \"^30.0.0\",\n    \"mockdate\": \"^3.0.5\",\n    \"npm-run-all2\": \"5.0.0\",\n    \"prettier\": \"2.8.8\",\n    \"rsbuild-config-cozy-app\": \"^0.7.1\",\n    \"stylint\": \"1.5.9\",\n    \"stylus-config-cozy-app\": \"^0.1.0\",\n    \"typescript\": \"4.9.5\",\n    \"worker-loader\": \"2.0.0\"\n  },\n  \"dependencies\": {\n    \"@sentry/react\": \"7.119.0\",\n    \"classnames\": \"2.3.1\",\n    \"cozy-bar\": \"^33.3.0\",\n    \"cozy-client\": \"^60.23.1\",\n    \"cozy-dataproxy-lib\": \"^4.13.0\",\n    \"cozy-device-helper\": \"^4.0.1\",\n    \"cozy-devtools\": \"^1.2.1\",\n    \"cozy-doctypes\": \"1.85.4\",\n    \"cozy-flags\": \"^4.6.1\",\n    \"cozy-harvest-lib\": \"^37.0.6\",\n    \"cozy-intent\": \"^2.30.1\",\n    \"cozy-interapp\": \"^0.17.1\",\n    \"cozy-keys-lib\": \"^7.0.0\",\n    \"cozy-logger\": \"^1.17.0\",\n    \"cozy-minilog\": \"3.9.1\",\n    \"cozy-pouch-link\": \"^60.19.0\",\n    \"cozy-realtime\": \"^5.8.0\",\n    \"cozy-search\": \"^0.25.3\",\n    \"cozy-sharing\": \"^30.3.1\",\n    \"cozy-stack-client\": \"^60.23.0\",\n    \"cozy-ui\": \"^138.10.0\",\n    \"cozy-ui-plus\": \"^7.1.0\",\n    \"cozy-viewer\": \"^28.0.7\",\n    \"date-fns\": \"2.30.0\",\n    \"diacritics\": \"1.3.0\",\n    \"filesize\": \"10.1.6\",\n    \"leaflet\": \"1.9.4\",\n    \"localforage\": \"1.10.0\",\n    \"lodash\": \"4.17.21\",\n    \"mime-types\": \"2.1.35\",\n    \"node-fetch\": \"2.6.7\",\n    \"node-polyglot\": \"2.4.2\",\n    \"prop-types\": \"15.8.1\",\n    \"react\": \"18.2.0\",\n    \"react-autosuggest\": \"10.1.0\",\n    \"react-dnd\": \"16.0.1\",\n    \"react-dnd-html5-backend\": \"16.0.1\",\n    \"react-dom\": \"18.2.0\",\n    \"react-dropzone\": \"14.3.8\",\n    \"react-inspector\": \"5.1.1\",\n    \"react-pdf\": \"^5.7.2\",\n    \"react-redux\": \"7.2.0\",\n    \"react-remove-scroll\": \"2.4.4\",\n    \"react-router-dom\": \"6.14.2\",\n    \"react-selecto\": \"^1.26.3\",\n    \"redux\": \"3.7.2\",\n    \"redux-logger\": \"3.0.6\",\n    \"redux-mock-store\": \"1.5.4\",\n    \"redux-thunk\": \"2.4.2\",\n    \"twake-i18n\": \"^0.3.4\",\n    \"whatwg-fetch\": \"3.0.0\"\n  }\n}\n"
  },
  {
    "path": "public/browserconfig.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<browserconfig>\n    <msapplication>\n        <tile>\n            <square150x150logo src=\"/mstile-150x150.png\"/>\n            <TileColor>#2d89ef</TileColor>\n        </tile>\n    </msapplication>\n</browserconfig>\n"
  },
  {
    "path": "public/manifest.json",
    "content": "{\n    \"name\": \"\",\n    \"icons\": [\n        {\n            \"src\": \"/android-chrome-192x192.png\",\n            \"sizes\": \"192x192\",\n            \"type\": \"image/png\"\n        },\n        {\n            \"src\": \"/android-chrome-512x512.png\",\n            \"sizes\": \"512x512\",\n            \"type\": \"image/png\"\n        }\n    ],\n    \"theme_color\": \"#ffffff\",\n    \"background_color\": \"#ffffff\",\n    \"display\": \"standalone\"\n}"
  },
  {
    "path": "renovate.json",
    "content": "{\n  \"extends\": [\"cozy\"]\n}\n"
  },
  {
    "path": "rsbuild.config.mjs",
    "content": "import { defineConfig, mergeRsbuildConfig } from '@rsbuild/core'\nimport { getRsbuildConfig } from 'rsbuild-config-cozy-app'\n\nconst config = getRsbuildConfig({\n  title: 'Twake Drive',\n  hasServices: true,\n  hasPublic: true,\n  hasIntents: true\n})\n\nconst mergedConfig = mergeRsbuildConfig(config, {\n  environments: {\n    main: {\n      output: {\n        copy: [\n          {\n            from: 'src/assets/onlyOffice',\n            to: 'onlyOffice'\n          },\n          {\n            from: 'src/assets/favicons',\n            to: 'favicons'\n          }\n        ]\n      }\n    }\n  },\n  resolve: {\n    alias: {\n      'react-pdf$': 'react-pdf/dist/esm/entry.webpack'\n    }\n  }\n})\n\nexport default defineConfig(mergedConfig)\n"
  },
  {
    "path": "src/components/App/App.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React, { Fragment } from 'react'\nimport { DndProvider } from 'react-dnd'\nimport { HTML5Backend } from 'react-dnd-html5-backend'\nimport { Provider } from 'react-redux'\n\nimport { BarProvider } from 'cozy-bar'\nimport flag from 'cozy-flags'\nimport { WebviewIntentProvider } from 'cozy-intent'\nimport { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport MoveValidationModals from '@/components/MoveValidationModals'\nimport PushBannerProvider from '@/components/PushBanner/PushBannerProvider'\nimport ClipboardProvider from '@/contexts/ClipboardProvider'\nimport { AcceptingSharingProvider } from '@/lib/AcceptingSharingContext'\nimport DriveProvider from '@/lib/DriveProvider'\nimport { ModalContextProvider } from '@/lib/ModalContext'\nimport { ViewSwitcherContextProvider } from '@/lib/ViewSwitcherContext'\nimport { PublicProvider } from '@/modules/public/PublicProvider'\nimport { onFileUploaded } from '@/modules/views/Upload/UploadUtils'\n\nconst Providers = ({ children }) => {\n  const { isMobile } = useBreakpoints()\n\n  const [DnDProvider, dnDProviderProps] =\n    flag('drive.virtualization.enabled') && !isMobile\n      ? [DndProvider, { backend: HTML5Backend }]\n      : [Fragment, {}]\n\n  return (\n    <BarProvider>\n      <PushBannerProvider>\n        <ClipboardProvider>\n          <AcceptingSharingProvider>\n            <ViewSwitcherContextProvider>\n              <ModalContextProvider>\n                <DnDProvider {...dnDProviderProps}>\n                  {children}\n                  <MoveValidationModals />\n                </DnDProvider>\n              </ModalContextProvider>\n            </ViewSwitcherContextProvider>\n          </AcceptingSharingProvider>\n        </ClipboardProvider>\n      </PushBannerProvider>\n    </BarProvider>\n  )\n}\n\nconst App = ({ isPublic, store, client, lang, polyglot, children }) => {\n  return (\n    <WebviewIntentProvider\n      methods={{\n        onFileUploaded: (file, isSuccess) =>\n          onFileUploaded({ file, isSuccess }, store.dispatch)\n      }}\n    >\n      <PublicProvider isPublic={isPublic}>\n        <Provider store={store}>\n          <DriveProvider client={client} lang={lang} polyglot={polyglot}>\n            <Providers>{children}</Providers>\n          </DriveProvider>\n        </Provider>\n      </PublicProvider>\n    </WebviewIntentProvider>\n  )\n}\n\nApp.propTypes = {\n  store: PropTypes.object,\n  lang: PropTypes.string,\n  polyglot: PropTypes.object,\n  client: PropTypes.object\n}\nexport default App\n"
  },
  {
    "path": "src/components/Bar.jsx",
    "content": "import React from 'react'\n\nimport { BarRight } from 'cozy-bar'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nexport const BarRightOnMobile = ({ children }) => {\n  const { isMobile } = useBreakpoints()\n\n  if (isMobile) {\n    return <BarRight>{children}</BarRight>\n  }\n\n  return children\n}\n"
  },
  {
    "path": "src/components/Button/BackButton.jsx",
    "content": "import React from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport PreviousIcon from 'cozy-ui/transpiled/react/Icons/Previous'\nimport { useI18n } from 'twake-i18n'\n\nexport const BackButton = ({ onClick, ...props }) => {\n  const { t } = useI18n()\n\n  return (\n    <IconButton onClick={onClick} {...props} aria-label={t('button.back')}>\n      <Icon icon={PreviousIcon} />\n    </IconButton>\n  )\n}\n\nexport default BackButton\n"
  },
  {
    "path": "src/components/Button/MoreButton.jsx",
    "content": "import React from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport DotsIcon from 'cozy-ui/transpiled/react/Icons/Dots'\nimport { useI18n } from 'twake-i18n'\n\nconst MoreButton = ({ disabled, onClick, ...props }) => {\n  const { t } = useI18n()\n\n  return (\n    <div>\n      <IconButton\n        data-testid=\"more-button\"\n        disabled={disabled}\n        onClick={onClick}\n        size=\"medium\"\n        aria-label={t('Toolbar.more')}\n        {...props}\n      >\n        <Icon icon={DotsIcon} />\n      </IconButton>\n    </div>\n  )\n}\n\nexport default MoreButton\n"
  },
  {
    "path": "src/components/Button/OpenFolderButton.tsx",
    "content": "import React, { FC } from 'react'\nimport { NavigateFunction } from 'react-router-dom'\n\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport { useI18n } from 'twake-i18n'\n\nimport { File } from '@/components/FolderPicker/types'\n\ninterface OpenFolderButtonProps {\n  folder: File\n  navigate: NavigateFunction\n}\n\nconst OpenFolderButton: FC<OpenFolderButtonProps> = ({ folder, navigate }) => {\n  const { t } = useI18n()\n\n  const handleNavigateFolder = (): void => {\n    if (folder._type === 'io.cozy.remote.nextcloud.files') {\n      return navigate(\n        `/nextcloud/${folder.cozyMetadata.sourceAccount}?path=${folder.path}`\n      )\n    }\n\n    return navigate(`/folder/${folder._id}`)\n  }\n\n  return (\n    <Button\n      color=\"success\"\n      label={t('OpenFolderButton.label')}\n      onClick={handleNavigateFolder}\n      size=\"small\"\n      variant=\"text\"\n      style={{ color: `var(--successContrastTextColor)` }}\n    />\n  )\n}\n\nexport { OpenFolderButton }\n"
  },
  {
    "path": "src/components/Button/index.jsx",
    "content": "export { default as MoreButton } from './MoreButton'\n"
  },
  {
    "path": "src/components/ColorPicker/ColorPicker.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport Avatar from 'cozy-ui/transpiled/react/Avatar'\nimport GridList from 'cozy-ui/transpiled/react/GridList'\nimport GridListTile from 'cozy-ui/transpiled/react/GridListTile'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport CheckIcon from 'cozy-ui/transpiled/react/Icons/Check'\nimport CrossIcon from 'cozy-ui/transpiled/react/Icons/Cross'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport {\n  COLORS,\n  NB_COLUMNS_MOBILE,\n  NB_COLUMNS_DESKTOP,\n  CELL_HEIGHT_MOBILE,\n  CELL_HEIGHT_DESKTOP,\n  CIRCLE_SIZE_MOBILE,\n  CIRCLE_SIZE_DESKTOP,\n  ICON_SIZE_MOBILE,\n  ICON_SIZE_DESKTOP\n} from './constants'\n\nimport styles from '@/styles/folder-customizer.styl'\n\n/**\n * ColorPicker component - displays a grid of colors and allows the user to select one\n * @param {Object} props\n * @param {string} props.selectedColor - Currently selected color\n * @param {Function} props.onColorSelect - Callback function when a color is selected\n */\nexport const ColorPicker = ({ selectedColor, onColorSelect }) => {\n  const { isMobile } = useBreakpoints()\n  return (\n    <>\n      <GridList\n        cols={isMobile ? NB_COLUMNS_MOBILE : NB_COLUMNS_DESKTOP}\n        cellHeight={isMobile ? CELL_HEIGHT_MOBILE : CELL_HEIGHT_DESKTOP}\n      >\n        <GridListTile className=\"u-ta-center\">\n          <Avatar\n            color=\"var(--papeBackgroundColor)\"\n            textColor=\"var(--white)\"\n            size={isMobile ? CIRCLE_SIZE_MOBILE : CIRCLE_SIZE_DESKTOP}\n            className={styles.noneIconFrame}\n          >\n            <IconButton onClick={() => onColorSelect()}>\n              <Icon\n                size={isMobile ? ICON_SIZE_MOBILE : ICON_SIZE_DESKTOP}\n                icon={CrossIcon}\n                color=\"textSecondary\"\n              />\n            </IconButton>\n          </Avatar>\n        </GridListTile>\n        {COLORS.map(color => (\n          <GridListTile key={color} className=\"u-ta-center\">\n            <Avatar\n              color={color}\n              textColor=\"var(--white)\"\n              size={isMobile ? CIRCLE_SIZE_MOBILE : CIRCLE_SIZE_DESKTOP}\n            >\n              <IconButton onClick={() => onColorSelect(color)}>\n                {selectedColor === color && (\n                  <Icon\n                    size={isMobile ? ICON_SIZE_MOBILE : ICON_SIZE_DESKTOP}\n                    icon={CheckIcon}\n                    color=\"white\"\n                  />\n                )}\n              </IconButton>\n            </Avatar>\n          </GridListTile>\n        ))}\n      </GridList>\n    </>\n  )\n}\n\nColorPicker.propTypes = {\n  selectedColor: PropTypes.string.isRequired,\n  onColorSelect: PropTypes.func.isRequired\n}\n\nColorPicker.displayName = 'ColorPicker'\n"
  },
  {
    "path": "src/components/ColorPicker/constants.js",
    "content": "export const COLORS = [\n  '#696c6f',\n  '#d3bfa4',\n  '#e1e3e6',\n  '#ff4d5e',\n  '#ff7750',\n  '#f5ac00',\n  '#ffd54c',\n  '#ffe082',\n  '#006bd8',\n  '#46a2ff',\n  '#91cef6',\n  '#afffeb',\n  '#2dd4ab',\n  '#66e49a',\n  '#6ad049',\n  '#00bf62',\n  '#713fa5',\n  '#a777e8',\n  '#ad95ff',\n  '#bfa9ff',\n  '#fba0b8',\n  '#e694e0',\n  '#e375cd'\n]\n\nexport const NB_COLUMNS_MOBILE = 6\nexport const NB_COLUMNS_DESKTOP = 8\nexport const CELL_HEIGHT_MOBILE = 56\nexport const CELL_HEIGHT_DESKTOP = 40\nexport const CIRCLE_SIZE_MOBILE = 40\nexport const CIRCLE_SIZE_DESKTOP = 36\nexport const ICON_SIZE_MOBILE = 18\nexport const ICON_SIZE_DESKTOP = 12\n"
  },
  {
    "path": "src/components/ColorPicker/index.jsx",
    "content": "export { ColorPicker } from './ColorPicker'\nexport { COLORS } from './constants'\n"
  },
  {
    "path": "src/components/Error/Empty.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\nimport { useLocation } from 'react-router-dom'\n\nimport flag from 'cozy-flags'\nimport Empty from 'cozy-ui/transpiled/react/Empty'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from './empty.styl'\n\nimport FolderEmptyIllu from '@/assets/icons/illu-folder-empty.svg'\nimport TrashIllustration from '@/assets/icons/illu-trash-empty.svg'\nimport { TRASH_DIR_ID } from '@/constants/config'\nimport { useCurrentFolderId, useDisplayedFolder } from '@/hooks'\nimport { useSharedDriveFolder } from '@/modules/shareddrives/hooks/useSharedDriveFolder'\nimport UploadButton from '@/modules/upload/UploadButton'\nimport CreateSharedDriveButton from '@/modules/views/SharedDrive/CreateSharedDriveButton'\n\nconst EmptyCanvas = ({\n  type,\n  canUpload,\n  localeKey,\n  hasTextMobileVersion,\n  onUploaded,\n  driveId\n}) => {\n  const { t } = useI18n()\n  const { isDesktop } = useBreakpoints()\n  const folderId = useCurrentFolderId()\n  const { displayedFolder } = useDisplayedFolder()\n  const { sharedDriveResult } = useSharedDriveFolder({ driveId, folderId })\n  const isSharedDriveEnabled = flag('drive.shared-drive.enabled')\n  const displayedSharedFolder = sharedDriveResult?.data\n\n  const IconToShow = type === 'trash' ? TrashIllustration : FolderEmptyIllu\n  const showSharedDriveLayout = type === 'sharing' && isSharedDriveEnabled\n  const showUploadLayout =\n    type === 'drive' || (type === 'sharing' && !isSharedDriveEnabled)\n  const title = localeKey ? t(`empty.${type}_title`) : undefined\n  const text =\n    (hasTextMobileVersion && !isDesktop && t(`empty.mobile_text`)) ||\n    (localeKey && t(`empty.${localeKey}_text`)) ||\n    (showUploadLayout && t('empty.text')) ||\n    (type === 'sharing' && isSharedDriveEnabled && t('empty.shared-drive_text'))\n\n  return (\n    <Empty\n      className={cx({ [styles['empty']]: showUploadLayout })}\n      data-testid=\"empty-folder\"\n      icon={\n        <div className=\"u-w-100\">\n          <Icon icon={IconToShow} size={160} />\n        </div>\n      }\n      iconSize={isDesktop ? 'medium' : 'large'}\n      centered={!isDesktop}\n      title={title}\n      text={\n        <>\n          {text}\n          {showUploadLayout && (\n            <span className=\"u-db u-mt-1\">\n              <UploadButton\n                disabled={!canUpload}\n                componentsProps={{\n                  button: { variant: 'secondary' }\n                }}\n                label={t('toolbar.menu_upload')}\n                displayedFolder={displayedSharedFolder || displayedFolder}\n                onUploaded={onUploaded}\n              />\n            </span>\n          )}\n          {showSharedDriveLayout && (\n            <span className=\"u-db u-mt-1\">\n              <CreateSharedDriveButton\n                variant=\"secondary\"\n                label={t('button.create')}\n              />\n            </span>\n          )}\n        </>\n      }\n    />\n  )\n}\n\nexport default EmptyCanvas\n\nexport const EmptyDrive = props => {\n  return <EmptyCanvas type=\"drive\" hasTextMobileVersion {...props} />\n}\n\nexport const EmptyTrash = props => (\n  <EmptyCanvas type=\"trash\" localeKey=\"trash\" {...props} />\n)\n\nexport const EmptyWrapper = ({\n  currentFolderId,\n  canUpload,\n  refreshFolderContent,\n  driveId\n}) => {\n  const { pathname } = useLocation()\n\n  if (pathname === '/sharings') {\n    return <EmptyCanvas type=\"sharing\" driveId={driveId} />\n  }\n  if (currentFolderId !== TRASH_DIR_ID) {\n    return (\n      <EmptyDrive\n        canUpload={canUpload}\n        onUploaded={refreshFolderContent}\n        driveId={driveId}\n      />\n    )\n  }\n\n  return <EmptyTrash canUpload={canUpload} onUploaded={refreshFolderContent} />\n}\n"
  },
  {
    "path": "src/components/Error/ErrorShare.jsx",
    "content": "import React from 'react'\n\nimport Empty from 'cozy-ui/transpiled/react/Empty'\nimport CloudBrokenIcon from 'cozy-ui/transpiled/react/Icons/CloudBroken'\nimport { useI18n } from 'twake-i18n'\n\nexport const ErrorShare = ({ errorType }) => {\n  const { t } = useI18n()\n  return (\n    <Empty\n      data-testid=\"empty-share\"\n      icon={CloudBrokenIcon}\n      title={t(`Error.${errorType}_title`)}\n      text={t(`Error.${errorType}_text`)}\n      componentsProps={{\n        icon: {\n          style: { height: '2rem' }\n        }\n      }}\n    />\n  )\n}\n\nexport default ErrorShare\n"
  },
  {
    "path": "src/components/Error/NotFound.jsx",
    "content": "import React from 'react'\n\nimport Empty from 'cozy-ui/transpiled/react/Empty'\nimport { useI18n } from 'twake-i18n'\n\nimport DesertIllustration from '@/assets/icons/illustrations-desert.svg'\n\nconst NotFound = () => {\n  const { t } = useI18n()\n\n  return (\n    <Empty\n      icon={DesertIllustration}\n      title={t('NotFound.title')}\n      text={t('NotFound.text')}\n    />\n  )\n}\n\nexport { NotFound }\n"
  },
  {
    "path": "src/components/Error/Oops.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport Empty from 'cozy-ui/transpiled/react/Empty'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from './oops.styl'\n\nimport EmptyIcon from '@/assets/icons/icon-folder-broken.svg'\n\nconst reload = () => {\n  window.location.reload()\n}\n\nconst Oops = ({ title, icon }) => {\n  const { t } = useI18n()\n\n  return (\n    <Empty\n      title={title ? title : t('error.open_folder')}\n      icon={icon ? icon : EmptyIcon}\n      className={styles['oops']}\n    >\n      <Button onClick={reload} label={t('error.button.reload')} />\n    </Empty>\n  )\n}\n\nOops.propTypes = {\n  title: PropTypes.string,\n  icon: PropTypes.node\n}\n\nexport default Oops\n"
  },
  {
    "path": "src/components/Error/empty.styl",
    "content": "@require 'settings/breakpoints'\n\n.empty\n    +medium-screen('min')\n        border 2px dashed var(--borderMainColor)\n        border-radius 1rem\n        margin-bottom 1.5rem\n        width 100%\n        max-width calc(100% - 7rem)\n"
  },
  {
    "path": "src/components/Error/oops.styl",
    "content": "@require 'settings/breakpoints'\n\n+medium-screen() // @stylint ignore\n    .oops\n        pointer-events auto\n"
  },
  {
    "path": "src/components/FileHistory/HistoryModal.jsx",
    "content": "import get from 'lodash/get'\nimport PropTypes from 'prop-types'\nimport React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { withClient, useCapabilities } from 'cozy-client'\nimport { Dialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport HistoryRow from 'cozy-ui/transpiled/react/HistoryRow'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport { translate } from 'twake-i18n'\n\nimport styles from './styles.styl'\n\nimport { CozyFile } from '@/models'\n\nconst formatDate = (date, f) => {\n  return f(date, 'dd LLLL - HH:mm')\n}\n\nconst HistoryModal = ({\n  file,\n  revisions,\n  client,\n  f,\n  t,\n  revisionsFetchStatus\n}) => {\n  const fileCollection = client.collection('io.cozy.files', {\n    driveId: file.driveId\n  })\n  const capabilities = useCapabilities(client)\n  const isFileVersioningEnabled = get(\n    capabilities,\n    'capabilities.file_versioning'\n  )\n  const navigate = useNavigate()\n\n  return (\n    <Dialog\n      onClose={() => navigate('../')}\n      open={true}\n      title={file.name}\n      content={\n        <>\n          <Typography variant=\"caption\" className={styles.HistoryRowCaption}>\n            {capabilities.fetchStatus === 'loading' && (\n              <span>{t('History.loading')}</span>\n            )}\n            {capabilities.fetchStatus === 'loaded' &&\n              isFileVersioningEnabled && (\n                <span>{t('History.description')}</span>\n              )}\n            {(capabilities.fetchStatus === 'failed' ||\n              (!isFileVersioningEnabled &&\n                capabilities.fetchStatus !== 'loading')) && (\n              <span>{t('History.noFileVersionEnabled')}</span>\n            )}\n          </Typography>\n          <HistoryRow\n            tag={t('History.current_version')}\n            primaryText={formatDate(file.updated_at, f)}\n            secondaryText={fileCollection.getBeautifulSize(file)}\n            downloadLink={() => {\n              fileCollection.download(file)\n            }}\n          />\n          {revisionsFetchStatus === 'loading' && (\n            <div className={styles.HistoryRowRevisionLoader}>\n              <Spinner size=\"xxlarge\" />\n            </div>\n          )}\n          {revisionsFetchStatus === 'loaded' &&\n            revisions.map(revision => {\n              return (\n                <HistoryRow\n                  primaryText={formatDate(revision.updated_at, f)}\n                  secondaryText={fileCollection.getBeautifulSize(revision)}\n                  key={revision._id}\n                  downloadLink={() => {\n                    fileCollection.download(\n                      file,\n                      revision.id,\n                      CozyFile.generateFileNameForRevision(file, revision, f)\n                    )\n                  }}\n                />\n              )\n            })}\n        </>\n      }\n    />\n  )\n}\n\nHistoryModal.propTypes = {\n  file: PropTypes.object.isRequired,\n  revisions: PropTypes.array,\n  client: PropTypes.object.isRequired,\n  f: PropTypes.func.isRequired,\n  t: PropTypes.func.isRequired,\n  revisionsFetchStatus: PropTypes.string.isRequired\n}\nexport default translate()(withClient(HistoryModal))\n"
  },
  {
    "path": "src/components/FileHistory/index.jsx",
    "content": "import React from 'react'\nimport { useParams } from 'react-router-dom'\n\nimport { Query, Q } from 'cozy-client'\n\nimport HistoryModal from './HistoryModal'\n\nconst FileHistory = () => {\n  const { fileId, driveId } = useParams()\n\n  return (\n    <Query\n      query={() => Q('io.cozy.files').getById(fileId).sharingById(driveId)}\n    >\n      {({ data: file, fetchStatus: fileFetchStatus }) => {\n        return (\n          <Query\n            query={client =>\n              client\n                .all('io.cozy.files.versions')\n                .where({\n                  relationships: { file: { data: { _id: fileId } } },\n                  updated_at: { $gt: null }\n                })\n                .sortBy([\n                  { 'relationships.file.data._id': 'desc' },\n                  { updated_at: 'desc' }\n                ])\n                .indexFields(['relationships.file.data._id', 'updated_at'])\n            }\n          >\n            {({ data: revisions, fetchStatus: revisionsFetchStatus }) => {\n              if (fileFetchStatus === 'loaded') {\n                return (\n                  <HistoryModal\n                    revisions={revisions}\n                    file={file}\n                    revisionsFetchStatus={revisionsFetchStatus}\n                    fileFetchStatus={fileFetchStatus}\n                  />\n                )\n              }\n              return null\n            }}\n          </Query>\n        )\n      }}\n    </Query>\n  )\n}\n\nexport default FileHistory\n"
  },
  {
    "path": "src/components/FileHistory/styles.styl",
    "content": ".HistoryRowRevisionLoader\n    display flex\n    justify-content center"
  },
  {
    "path": "src/components/FilesRealTimeQueries.jsx",
    "content": "import debounce from 'lodash/debounce'\nimport { memo, useEffect } from 'react'\n\nimport { useClient, Mutations } from 'cozy-client'\nimport { ensureFilePath } from 'cozy-client/dist/models/file'\nimport { receiveMutationResult } from 'cozy-client/dist/store'\n\nimport { buildFileOrFolderByIdQuery } from '@/queries'\n\nconst REALTIME_DEBOUNCE_TIME = 500\n\nconst bufferCreatedFiles = new Map()\nconst bufferUpdatedFiles = new Map()\nconst bufferDeletedFiles = new Map()\n\nconst getParentFolder = async (client, dirId) => {\n  let parentDir = client.getDocumentFromState('io.cozy.files', dirId)\n  if (!parentDir) {\n    // Parent is not in the store: query it\n    const parentQuery = buildFileOrFolderByIdQuery(dirId)\n    const parentResult = await client.fetchQueryAndGetFromState({\n      definition: parentQuery.definition(),\n      options: parentQuery.options\n    })\n    parentDir = parentResult.data\n  }\n  return parentDir\n}\n\nexport const ensureFileHasPath = async (doc, client) => {\n  if (doc.path) return doc\n\n  const parentDir = await getParentFolder(client, doc.dir_id)\n  return ensureFilePath(doc, parentDir)\n}\n\n/**\n * This method process the bufferised files after debounced realtime events\n * It creates the related mutation and dispatch it to the store.\n * Once done, the buffer is emptied\n *\n * @param {CozyClient} client - The CozyClient instance\n * @param {string} mutationType - Either 'created', 'updated' or 'deleted'\n * @returns {Promise<void>}\n */\nconst processEvents = async (client, mutationType) => {\n  let bufferFiles, mutationFn, multipleMutationFn\n  if (mutationType === 'created') {\n    bufferFiles = bufferCreatedFiles\n    mutationFn = Mutations.createDocument\n    multipleMutationFn = Mutations.createDocuments\n  }\n  if (mutationType === 'updated') {\n    bufferFiles = bufferUpdatedFiles\n    mutationFn = Mutations.updateDocument\n    multipleMutationFn = Mutations.updateDocuments\n  }\n  if (mutationType === 'deleted') {\n    bufferFiles = bufferDeletedFiles\n    mutationFn = Mutations.deleteDocument\n    multipleMutationFn = Mutations.deleteDocuments\n  }\n  if (bufferFiles.size === 0) return\n\n  const fileIdsToProcess = bufferFiles.keys()\n  let filesByFolder = {}\n  if (mutationType == 'deleted') {\n    filesByFolder['io.cozy.files.trash-dir'] = Array.from(\n      bufferFiles.values()\n    ).filter(file => file.dir_id === 'io.cozy.files.trash-dir')\n  } else {\n    filesByFolder = groupFilesByFolder(bufferFiles)\n  }\n\n  for (const folderId in filesByFolder) {\n    const files = []\n    const folder = await getParentFolder(client, folderId)\n    for (const file of filesByFolder[folderId]) {\n      const fileWithPath = ensureFilePath(file, folder)\n      files.push(fileWithPath)\n    }\n    if (files.length < 1) {\n      // No files to process, early return\n      return\n    }\n    const mutation =\n      files.length > 1 ? multipleMutationFn(files) : mutationFn(files[0])\n\n    client.dispatch(\n      receiveMutationResult(\n        client.generateRandomId(),\n        { data: files },\n        {},\n        mutation\n      )\n    )\n  }\n  // Remove processed files from buffer\n  // Do not clear all at once in case pending events arrived during the processing\n  for (const fileId of fileIdsToProcess) {\n    bufferFiles.delete(fileId)\n  }\n}\n\nconst debouncedDispatchEvents = debounce(\n  processEvents,\n  REALTIME_DEBOUNCE_TIME,\n  {\n    leading: true, // Do not debounce first event\n    trailing: true // Execute all at the end of debounce\n  }\n)\n\n/**\n * Associate files to their parent folder\n *\n * @param {Map<string, import('cozy-client/types/types').IOCozyFile} files - The files to group\n * @returns {object} The grouped files\n */\nconst groupFilesByFolder = files => {\n  const filesByFolder = {}\n  files.forEach(file => {\n    const folderId = file.dir_id\n    if (!filesByFolder[folderId]) {\n      filesByFolder[folderId] = []\n    }\n    filesByFolder[folderId].push(file)\n  })\n  return filesByFolder\n}\n\n/**\n * Normalizes an object representing a CouchDB document\n *\n * Ensures existence of `_type`\n *\n * @public\n * @param {CouchDBDocument} couchDBDoc - object representing the document\n * @returns {CozyClientDocument} full normalized document\n */\nconst normalizeDoc = (couchDBDoc, doctype) => {\n  return {\n    id: couchDBDoc._id,\n    _type: doctype,\n    ...couchDBDoc\n  }\n}\n\n/**\n * Component that subscribes to io.cozy.files document changes and keep the\n * internal store updated. This is a copy of RealTimeQueries from cozy-client\n * with a tweak to merge the changes with the existing document from the store.\n * You can have more detail on the problematic we are solving here:\n * https://github.com/cozy/cozy-client/issues/1412\n *\n * @param {object} options\n * @param {string} options.doctype - The doctype to watch.\n * @param {Function} [options.computeDocBeforeDispatchCreate]\n * @param {Function} [options.computeDocBeforeDispatchUpdate]\n * @param {Function} [options.computeDocBeforeDispatchDelete]\n * @returns {null} The component does not render anything.\n */\nconst FilesRealTimeQueries = ({\n  doctype = 'io.cozy.files',\n  computeDocBeforeDispatchCreate = ensureFileHasPath,\n  computeDocBeforeDispatchUpdate = ensureFileHasPath,\n  computeDocBeforeDispatchDelete = (doc, client) =>\n    ensureFileHasPath({ ...doc, _deleted: true }, client)\n}) => {\n  const client = useClient()\n\n  useEffect(() => {\n    const { realtime } = client.plugins || {}\n\n    if (!realtime) {\n      throw new Error(\n        'You must include the realtime plugin to use RealTimeQueries'\n      )\n    }\n\n    const makeHandler = (buffer, event) => couchDBDoc => {\n      const normalized = normalizeDoc(couchDBDoc, doctype)\n\n      buffer.set(couchDBDoc._id, normalized)\n      debouncedDispatchEvents(client, event)\n    }\n\n    const eventHandlers = {\n      created: makeHandler(bufferCreatedFiles, 'created'),\n      updated: makeHandler(bufferUpdatedFiles, 'updated'),\n      deleted: makeHandler(bufferDeletedFiles, 'deleted')\n    }\n\n    const subscribeToEvents = async () => {\n      await Promise.all(\n        Object.entries(eventHandlers).map(([event, handler]) =>\n          realtime.subscribe(event, doctype, handler)\n        )\n      )\n    }\n\n    subscribeToEvents().catch(err =>\n      // eslint-disable-next-line no-console\n      console.error('Failed to subscribe to realtime events:', err)\n    )\n\n    return () => {\n      Object.entries(eventHandlers).forEach(([event, handler]) =>\n        realtime.unsubscribe(event, doctype, handler)\n      )\n    }\n  }, [\n    client,\n    doctype,\n    computeDocBeforeDispatchCreate,\n    computeDocBeforeDispatchUpdate,\n    computeDocBeforeDispatchDelete\n  ])\n\n  return null\n}\n\nexport default memo(FilesRealTimeQueries)\n"
  },
  {
    "path": "src/components/FilesViewerLoading.jsx",
    "content": "import React from 'react'\n\nimport Backdrop from 'cozy-ui/transpiled/react/Backdrop'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\n\nconst FilesViewerLoading = () => (\n  <Backdrop isOver open>\n    <Spinner size=\"xxlarge\" middle noMargin color=\"var(--white)\" />\n  </Backdrop>\n)\n\nexport { FilesViewerLoading }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPicker.spec.jsx",
    "content": "import { render, fireEvent, screen } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\nimport { useSharingContext } from 'cozy-sharing'\n\nimport AppLike from 'test/components/AppLike'\n\nimport { FolderPicker } from '@/components/FolderPicker/FolderPicker'\n\njest.mock('cozy-keys-lib', () => ({\n  useVaultClient: jest.fn()\n}))\n\njest.mock('cozy-sharing', () => ({\n  ...jest.requireActual('cozy-sharing'),\n  useSharingContext: jest.fn(),\n  SharingCollection: {\n    data: jest.fn().mockResolvedValue({ data: [] })\n  }\n}))\n\nuseSharingContext.mockReturnValue({ byDocId: [] })\n\njest.mock('@/components/FolderPicker/FolderPickerBody', () => ({\n  FolderPickerBody: jest\n    .fn()\n    .mockImplementation(({ isFolderCreationDisplayed }) => (\n      <div data-testid=\"folder-picker-body\">\n        <div>Mocked Folder Picker Body</div>\n        {isFolderCreationDisplayed && (\n          <div data-testid=\"name-input\">\n            <input placeholder=\"Folder name\" />\n          </div>\n        )}\n      </div>\n    ))\n}))\n\ndescribe('FolderPicker', () => {\n  const cozyFile = {\n    id: 'file123',\n    _id: 'file123',\n    _type: 'io.cozy.files',\n    dir_id: 'folder123',\n    name: 'penguins.jpg'\n  }\n\n  const cozyFolder = {\n    id: 'folder123',\n    _id: 'folder123',\n    _type: 'io.cozy.files',\n    dir_id: 'io.cozy.files.root-dir',\n    name: 'Photos'\n  }\n\n  const rootCozyFolder = {\n    id: 'io.cozy.files.root-dir',\n    _id: 'io.cozy.files.root-dir',\n    _type: 'io.cozy.files'\n  }\n\n  const onCloseSpy = jest.fn()\n  const onConfirmSpy = jest.fn()\n\n  const setup = () => {\n    const mockClient = createMockClient({\n      queries: {\n        'io.cozy.files/io.cozy.files.root-dir': {\n          doctype: 'io.cozy.files',\n          definition: {\n            doctype: 'io.cozy.files',\n            id: 'io.cozy.files.root-dir'\n          },\n          data: [rootCozyFolder]\n        }\n      }\n    })\n\n    return render(\n      <AppLike client={mockClient}>\n        <FolderPicker\n          currentFolder={cozyFolder}\n          entries={[cozyFile]}\n          onClose={onCloseSpy}\n          onConfirm={onConfirmSpy}\n        />\n      </AppLike>\n    )\n  }\n\n  it('should be able to move inside another folder', async () => {\n    setup()\n\n    expect(screen.getByText('Photos')).toBeInTheDocument()\n\n    const backButton = screen.getByRole('button', {\n      name: 'Back'\n    })\n    fireEvent.click(backButton)\n    await screen.findByText('Files')\n\n    const moveButton = screen.queryByRole('button', {\n      name: 'Move'\n    })\n    fireEvent.click(moveButton)\n    expect(onConfirmSpy).toHaveBeenCalledWith(rootCozyFolder)\n  })\n\n  it('should display the folder creation input', async () => {\n    setup()\n\n    const addFolderButton = screen.queryByRole('button', {\n      name: 'Add a folder'\n    })\n    fireEvent.click(addFolderButton)\n\n    const filenameInput = await screen.findByTestId('name-input')\n    expect(filenameInput).toBeInTheDocument()\n  })\n\n  it('should render with the provided folder', async () => {\n    setup()\n\n    expect(screen.getByTestId('folder-picker-body')).toBeInTheDocument()\n\n    const {\n      FolderPickerBody\n    } = require('@/components/FolderPicker/FolderPickerBody')\n\n    const props = FolderPickerBody.mock.calls[0][0]\n\n    expect(props.folder).toEqual(cozyFolder)\n    expect(props.entries).toEqual([cozyFile])\n    expect(typeof props.navigateTo).toBe('function')\n  })\n\n  it('should allow folder creation when canCreateFolder is true', async () => {\n    const mockClient = createMockClient()\n    render(\n      <AppLike client={mockClient}>\n        <FolderPicker\n          currentFolder={cozyFolder}\n          entries={[cozyFile]}\n          onClose={onCloseSpy}\n          onConfirm={onConfirmSpy}\n          canCreateFolder={true}\n        />\n      </AppLike>\n    )\n\n    expect(screen.getByTestId('folder-picker-body')).toBeInTheDocument()\n  })\n})\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPicker.tsx",
    "content": "import React, { useState } from 'react'\n\nimport { FixedDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport { makeStyles } from 'cozy-ui/transpiled/react/styles'\n\nimport { FolderPickerBody } from '@/components/FolderPicker/FolderPickerBody'\nimport { FolderPickerFooter } from '@/components/FolderPicker/FolderPickerFooter'\nimport { FolderPickerHeader } from '@/components/FolderPicker/FolderPickerHeader'\nimport { FolderPickerTopbar } from '@/components/FolderPicker/FolderPickerTopbar'\nimport { File, FolderPickerEntry } from '@/components/FolderPicker/types'\n\ninterface FolderPickerSlotProps {\n  header?: {\n    title?: string\n    subTitle?: string\n  }\n  footer?: {\n    confirmLabel?: string\n    cancelLabel?: string\n  }\n}\n\ninterface FolderPickerProps {\n  currentFolder: File\n  entries: FolderPickerEntry[]\n  onConfirm: (folder: File) => void\n  onClose: () => void | Promise<void>\n  isBusy: boolean\n  canCreateFolder?: boolean\n  slotProps?: FolderPickerSlotProps\n  showNextcloudFolder?: boolean\n  canPickEntriesParentFolder?: boolean\n  isPublic?: boolean\n  showSharedDriveFolder?: boolean\n}\n\nconst useStyles = makeStyles({\n  paper: {\n    height: '100%',\n    '& .MuiDialogContent-root': {\n      padding: '0'\n    },\n    '& .MuiDialogTitle-root': {\n      padding: '0'\n    }\n  }\n})\n\nconst FolderPicker: React.FC<FolderPickerProps> = ({\n  currentFolder,\n  entries,\n  onConfirm,\n  onClose,\n  isBusy,\n  canCreateFolder = true,\n  slotProps,\n  showNextcloudFolder = false,\n  canPickEntriesParentFolder = false,\n  isPublic = false,\n  showSharedDriveFolder = false\n}) => {\n  const [folder, setFolder] = useState<File>(currentFolder)\n\n  const [isFolderCreationDisplayed, setFolderCreationDisplayed] =\n    useState<boolean>(false)\n  const classes = useStyles()\n\n  const showFolderCreation = (): void => {\n    setFolderCreationDisplayed(true)\n  }\n\n  const hideFolderCreation = (): void => {\n    setFolderCreationDisplayed(false)\n  }\n\n  const navigateTo = (folder: File): void => {\n    setFolder(folder)\n    setFolderCreationDisplayed(false)\n  }\n\n  return (\n    <FixedDialog\n      open\n      onClose={onClose}\n      size=\"large\"\n      classes={{\n        paper: classes.paper\n      }}\n      title={\n        <>\n          <FolderPickerHeader entries={entries} {...slotProps?.header} />\n          <FolderPickerTopbar\n            navigateTo={navigateTo}\n            folder={folder}\n            canCreateFolder={canCreateFolder}\n            showFolderCreation={showFolderCreation}\n          />\n        </>\n      }\n      content={\n        <FolderPickerBody\n          folder={folder}\n          navigateTo={navigateTo}\n          entries={entries}\n          isFolderCreationDisplayed={isFolderCreationDisplayed}\n          hideFolderCreation={hideFolderCreation}\n          showNextcloudFolder={showNextcloudFolder}\n          isPublic={isPublic}\n          showSharedDriveFolder={showSharedDriveFolder}\n        />\n      }\n      actions={\n        <FolderPickerFooter\n          onConfirm={onConfirm}\n          onClose={onClose}\n          entries={entries}\n          folder={folder}\n          isBusy={isBusy}\n          canPickEntriesParentFolder={canPickEntriesParentFolder}\n          {...slotProps?.footer}\n        />\n      }\n    />\n  )\n}\n\nexport { FolderPicker }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerAddFolderItem.tsx",
    "content": "import React, { FC } from 'react'\nimport { useDispatch } from 'react-redux'\n\nimport { useClient } from 'cozy-client'\nimport Divider from 'cozy-ui/transpiled/react/Divider'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconFolder from 'cozy-ui/transpiled/react/Icons/FileTypeFolder'\nimport ListItem from 'cozy-ui/transpiled/react/ListItem'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport FilenameInput from '@/modules/filelist/FilenameInput'\nimport { createFolder } from '@/modules/navigation/duck'\n\ninterface FolderPickerAddFolderItemProps {\n  currentFolderId: string\n  visible: boolean\n  afterSubmit: () => void\n  afterAbort: () => void\n  driveId?: string\n}\n\nconst FolderPickerAddFolderItem: FC<FolderPickerAddFolderItemProps> = ({\n  currentFolderId,\n  visible,\n  afterSubmit,\n  afterAbort,\n  driveId\n}) => {\n  const { isMobile } = useBreakpoints()\n  const gutters = isMobile ? 'default' : 'double'\n  const dispatch = useDispatch()\n  const { showAlert } = useAlert()\n  const { t } = useI18n()\n  const client = useClient()\n\n  const handleSubmit = (name: string): void => {\n    dispatch(\n      createFolder(client, name, currentFolderId, { showAlert, t }, driveId)\n    )\n    if (typeof afterSubmit === 'function') {\n      afterSubmit()\n    }\n  }\n\n  const handleAbort = (accidental: boolean): void => {\n    if (accidental) {\n      showAlert({\n        message: t('alert.folder_abort'),\n        severity: 'secondary'\n      })\n    }\n    if (typeof afterAbort === 'function') {\n      afterAbort()\n    }\n  }\n\n  if (visible) {\n    return (\n      <>\n        <ListItem gutters={gutters}>\n          <ListItemIcon>\n            <Icon icon={IconFolder} size={32} />\n          </ListItemIcon>\n          <FilenameInput onSubmit={handleSubmit} onAbort={handleAbort} />\n        </ListItem>\n        <Divider />\n      </>\n    )\n  }\n\n  return null\n}\n\nexport { FolderPickerAddFolderItem }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerBody.spec.jsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport React from 'react'\n\nimport { FolderPickerBody } from '@/components/FolderPicker/FolderPickerBody'\nimport { ROOT_DIR_ID, SHARED_DRIVES_DIR_ID } from '@/constants/config'\n\njest.mock('components/FolderPicker/FolderPickerContentCozy', () => ({\n  FolderPickerContentCozy: () => <div>FolderPickerContentCozy</div>\n}))\njest.mock('components/FolderPicker/FolderPickerContentNextcloud', () => ({\n  FolderPickerContentNextcloud: () => <div>FolderPickerContentNextcloud</div>\n}))\njest.mock('components/FolderPicker/FolderPickerContentSharedDrive', () => ({\n  FolderPickerContentSharedDrive: () => (\n    <div>FolderPickerContentSharedDrive</div>\n  )\n}))\njest.mock('components/FolderPicker/FolderPickerContentSharedDriveRoot', () => ({\n  FolderPickerContentSharedDriveRoot: () => (\n    <div>FolderPickerContentSharedDriveRoot</div>\n  )\n}))\n\ndescribe('FolderPickerBody', () => {\n  const defaultProps = {\n    entries: [],\n    navigateTo: jest.fn(),\n    isFolderCreationDisplayed: false,\n    hideFolderCreation: jest.fn()\n  }\n\n  it('return cozy folder', () => {\n    const cozyFolder = {\n      _type: 'io.cozy.files'\n    }\n    render(<FolderPickerBody folder={cozyFolder} {...defaultProps} />)\n    expect(screen.getByText('FolderPickerContentCozy')).toBeInTheDocument()\n  })\n\n  it('return Nextcloud folder', () => {\n    const nextcloudFolder = {\n      _type: 'io.cozy.remote.nextcloud.files'\n    }\n    render(<FolderPickerBody folder={nextcloudFolder} {...defaultProps} />)\n    expect(screen.getByText('FolderPickerContentNextcloud')).toBeInTheDocument()\n  })\n\n  it(\"should display content of recipient's shared drive folder\", () => {\n    const sharedDriveFolder = {\n      _type: 'io.cozy.files',\n      _id: 'folder-123',\n      name: 'Shared Team Folder',\n      driveId: 'sharing-456',\n      dir_id: 'parent-folder'\n    }\n\n    render(<FolderPickerBody folder={sharedDriveFolder} {...defaultProps} />)\n    expect(\n      screen.getByText('FolderPickerContentSharedDrive')\n    ).toBeInTheDocument()\n  })\n\n  it('should display content of `Drives` folder', () => {\n    const drivesFolder = {\n      _type: 'io.cozy.files',\n      _id: SHARED_DRIVES_DIR_ID,\n      dir_id: ROOT_DIR_ID,\n      name: 'Drives'\n    }\n\n    render(\n      <FolderPickerBody\n        folder={drivesFolder}\n        {...defaultProps}\n        showSharedDriveFolder={true}\n      />\n    )\n    expect(\n      screen.getByText('FolderPickerContentSharedDriveRoot')\n    ).toBeInTheDocument()\n  })\n\n  it('should fallback to cozy content for regular folders without driveId', () => {\n    const regularFolder = {\n      _type: 'io.cozy.files',\n      _id: 'regular-folder-123',\n      name: 'My Documents',\n      dir_id: 'parent-folder'\n    }\n\n    render(<FolderPickerBody folder={regularFolder} {...defaultProps} />)\n    expect(screen.getByText('FolderPickerContentCozy')).toBeInTheDocument()\n  })\n})\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerBody.tsx",
    "content": "import React from 'react'\n\nimport { FolderPickerContentSharedDriveRoot } from './FolderPickerContentSharedDriveRoot'\n\nimport { FolderPickerContentCozy } from '@/components/FolderPicker/FolderPickerContentCozy'\nimport { FolderPickerContentNextcloud } from '@/components/FolderPicker/FolderPickerContentNextcloud'\nimport { FolderPickerContentPublic } from '@/components/FolderPicker/FolderPickerContentPublic'\nimport { FolderPickerContentSharedDrive } from '@/components/FolderPicker/FolderPickerContentSharedDrive'\nimport { File, FolderPickerEntry } from '@/components/FolderPicker/types'\nimport { ROOT_DIR_ID, SHARED_DRIVES_DIR_ID } from '@/constants/config'\n\ninterface FolderPickerBodyProps {\n  folder: File\n  entries: FolderPickerEntry[]\n  navigateTo: (folder: File) => void\n  isFolderCreationDisplayed: boolean\n  hideFolderCreation: () => void\n  showNextcloudFolder?: boolean\n  isPublic?: boolean\n  showSharedDriveFolder?: boolean\n}\n\nconst FolderPickerBody: React.FC<FolderPickerBodyProps> = ({\n  folder,\n  entries,\n  navigateTo,\n  isFolderCreationDisplayed,\n  hideFolderCreation,\n  showNextcloudFolder,\n  isPublic,\n  showSharedDriveFolder\n}) => {\n  if (folder._type === 'io.cozy.remote.nextcloud.files') {\n    return (\n      <FolderPickerContentNextcloud\n        folder={folder}\n        entries={entries}\n        navigateTo={navigateTo}\n      />\n    )\n  }\n\n  if (isPublic) {\n    return (\n      <FolderPickerContentPublic\n        folder={folder}\n        isFolderCreationDisplayed={isFolderCreationDisplayed}\n        hideFolderCreation={hideFolderCreation}\n        entries={entries}\n        navigateTo={navigateTo}\n        showNextcloudFolder={showNextcloudFolder}\n      />\n    )\n  }\n\n  // Display content of recipient's shared drive folder\n  if (folder.driveId) {\n    return (\n      <FolderPickerContentSharedDrive\n        folder={folder}\n        isFolderCreationDisplayed={isFolderCreationDisplayed}\n        hideFolderCreation={hideFolderCreation}\n        entries={entries}\n        navigateTo={navigateTo}\n      />\n    )\n  }\n\n  if (\n    folder.dir_id === ROOT_DIR_ID &&\n    folder._id === SHARED_DRIVES_DIR_ID &&\n    showSharedDriveFolder\n  ) {\n    return (\n      <FolderPickerContentSharedDriveRoot\n        folder={folder}\n        isFolderCreationDisplayed={isFolderCreationDisplayed}\n        hideFolderCreation={hideFolderCreation}\n        entries={entries}\n        navigateTo={navigateTo}\n        showNextcloudFolder={showNextcloudFolder}\n      />\n    )\n  }\n\n  return (\n    <FolderPickerContentCozy\n      folder={folder}\n      isFolderCreationDisplayed={isFolderCreationDisplayed}\n      hideFolderCreation={hideFolderCreation}\n      entries={entries}\n      navigateTo={navigateTo}\n      showNextcloudFolder={showNextcloudFolder}\n      showSharedDriveFolder={showSharedDriveFolder}\n    />\n  )\n}\n\nexport { FolderPickerBody }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerContentCozy.tsx",
    "content": "import React, { useMemo } from 'react'\n\nimport { useQuery } from 'cozy-client'\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport List from 'cozy-ui/transpiled/react/List'\n\nimport { FolderPickerListItem } from './FolderPickerListItem'\n\nimport { FolderPickerAddFolderItem } from '@/components/FolderPicker/FolderPickerAddFolderItem'\nimport { FolderPickerContentLoadMore } from '@/components/FolderPicker/FolderPickerContentLoadMore'\nimport { FolderPickerContentLoader } from '@/components/FolderPicker/FolderPickerContentLoader'\nimport { isInvalidMoveTarget } from '@/components/FolderPicker/helpers'\nimport { computeNextcloudRootFolder } from '@/components/FolderPicker/helpers'\nimport type { File, FolderPickerEntry } from '@/components/FolderPicker/types'\nimport { ROOT_DIR_ID } from '@/constants/config'\nimport { buildMoveOrImportQuery, buildMagicFolderQuery } from '@/queries'\n\ninterface FolderPickerContentCozyProps {\n  folder: IOCozyFile\n  isFolderCreationDisplayed: boolean\n  hideFolderCreation: () => void\n  entries: FolderPickerEntry[]\n  navigateTo: (folder: import('./types').File) => void\n  showNextcloudFolder?: boolean\n  showSharedDriveFolder?: boolean\n}\n\nconst FolderPickerContentCozy: React.FC<FolderPickerContentCozyProps> = ({\n  folder,\n  isFolderCreationDisplayed,\n  hideFolderCreation,\n  entries,\n  navigateTo,\n  showNextcloudFolder,\n  showSharedDriveFolder\n}) => {\n  const contentQuery = buildMoveOrImportQuery(folder._id)\n  const {\n    fetchStatus,\n    data: filesData,\n    hasMore,\n    fetchMore\n  } = useQuery(contentQuery.definition, contentQuery.options) as unknown as {\n    fetchStatus: string\n    data?: IOCozyFile[]\n    hasMore: boolean\n    fetchMore: () => void\n  }\n\n  const sharedFolderQuery = buildMagicFolderQuery({\n    id: 'io.cozy.files.shared-drives-dir',\n    enabled: folder._id === ROOT_DIR_ID\n  })\n  const sharedFolderResult = useQuery(\n    sharedFolderQuery.definition,\n    sharedFolderQuery.options\n  ) as unknown as {\n    fetchStatus: string\n    data?: IOCozyFile[]\n  }\n\n  const files: IOCozyFile[] = useMemo(() => {\n    if (\n      folder._id === ROOT_DIR_ID &&\n      (showNextcloudFolder || showSharedDriveFolder)\n    ) {\n      return [\n        ...(sharedFolderResult.fetchStatus === 'loaded'\n          ? (sharedFolderResult.data ?? [])\n          : []),\n        ...(filesData ?? [])\n      ]\n    }\n    return [...(filesData ?? [])]\n  }, [\n    folder._id,\n    showNextcloudFolder,\n    showSharedDriveFolder,\n    filesData,\n    sharedFolderResult.fetchStatus,\n    sharedFolderResult.data\n  ])\n\n  const handleClick = (file: File): void => {\n    if (isDirectory(file)) {\n      navigateTo(file)\n    }\n\n    if (\n      file._type === 'io.cozy.files' &&\n      file.cozyMetadata?.createdByApp === 'nextcloud' &&\n      file.cozyMetadata.sourceAccount\n    ) {\n      const nextcloudRootFolder = computeNextcloudRootFolder({\n        sourceAccount: file.cozyMetadata.sourceAccount,\n        instanceName: file.metadata.instanceName\n      })\n      navigateTo(nextcloudRootFolder)\n    }\n  }\n\n  return (\n    <List>\n      <FolderPickerAddFolderItem\n        currentFolderId={folder._id}\n        visible={isFolderCreationDisplayed}\n        afterSubmit={hideFolderCreation}\n        afterAbort={hideFolderCreation}\n      />\n      <FolderPickerContentLoader\n        fetchStatus={fetchStatus}\n        hasNoData={files.length === 0}\n      >\n        {files.map((file, index) => (\n          <FolderPickerListItem\n            key={file._id}\n            file={file}\n            disabled={isInvalidMoveTarget(entries, file)}\n            onClick={handleClick}\n            showDivider={index !== files.length - 1}\n          />\n        ))}\n        <FolderPickerContentLoadMore hasMore={hasMore} fetchMore={fetchMore} />\n      </FolderPickerContentLoader>\n    </List>\n  )\n}\n\nexport { FolderPickerContentCozy }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerContentLoadMore.tsx",
    "content": "import React from 'react'\n\nimport LoadMoreButton from '@/modules/filelist/LoadMore'\n\ninterface FolderPickerContentLoadMoreProps {\n  hasMore: boolean\n  fetchMore: () => void\n}\n\nconst FolderPickerContentLoadMore: React.FC<\n  FolderPickerContentLoadMoreProps\n> = ({ hasMore, fetchMore }) => {\n  if (hasMore) {\n    return <LoadMoreButton onClick={fetchMore} isLoading={false} />\n  }\n\n  return null\n}\n\nexport { FolderPickerContentLoadMore }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerContentLoader.tsx",
    "content": "import React, { ReactNode } from 'react'\n\nimport ListItemSkeleton from 'cozy-ui/transpiled/react/Skeletons/ListItemSkeleton'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { EmptyDrive } from '@/components/Error/Empty'\nimport Oops from '@/components/Error/Oops'\n\ninterface FolderPickerContentLoaderProps {\n  fetchStatus: string\n  hasNoData?: boolean\n  children: ReactNode\n}\n\nconst FolderPickerContentLoader: React.FC<FolderPickerContentLoaderProps> = ({\n  fetchStatus,\n  hasNoData,\n  children\n}) => {\n  const { isMobile } = useBreakpoints()\n  const gutters = isMobile ? 'default' : 'double'\n\n  if (fetchStatus === 'loading')\n    return (\n      <>\n        {Array.from({ length: 8 }, (_, index) => (\n          <ListItemSkeleton\n            key={`key_file_placeholder_${index}`}\n            gutters={gutters}\n            hasSecondary\n            divider={index !== 7}\n          />\n        ))}\n      </>\n    )\n  else if (fetchStatus === 'failed') return <Oops />\n  else if (fetchStatus === 'loaded' && hasNoData)\n    return <EmptyDrive canUpload={false} />\n  else return <>{children}</>\n}\n\nexport { FolderPickerContentLoader }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerContentNextcloud.tsx",
    "content": "import React from 'react'\n\nimport { useQuery } from 'cozy-client'\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport { NextcloudFile } from 'cozy-client/types/types'\nimport List from 'cozy-ui/transpiled/react/List'\n\nimport { FolderPickerListItem } from './FolderPickerListItem'\n\nimport { FolderPickerContentLoader } from '@/components/FolderPicker/FolderPickerContentLoader'\nimport { isInvalidMoveTarget } from '@/components/FolderPicker/helpers'\nimport type { File, FolderPickerEntry } from '@/components/FolderPicker/types'\nimport { buildNextcloudFolderQuery } from '@/queries'\n\ninterface Props {\n  folder: NextcloudFile\n  entries: FolderPickerEntry[] // Update with the appropriate type\n  navigateTo: (folder: import('./types').File) => void // Update with the appropriate type\n}\n\nconst FolderPickerContentNextcloud: React.FC<Props> = ({\n  folder,\n  entries,\n  navigateTo\n}) => {\n  const nextcloudQuery = buildNextcloudFolderQuery({\n    sourceAccount: folder.cozyMetadata.sourceAccount,\n    path: folder.path\n  })\n\n  const { fetchStatus, data } = useQuery(\n    nextcloudQuery.definition,\n    nextcloudQuery.options\n  ) as {\n    fetchStatus: string\n    data?: NextcloudFile[]\n  }\n\n  const files = data ?? []\n\n  const handleClick = (file: File): void => {\n    if (isDirectory(file)) {\n      navigateTo(file)\n    }\n  }\n\n  return (\n    <List>\n      <FolderPickerContentLoader\n        fetchStatus={fetchStatus}\n        hasNoData={files.length === 0}\n      >\n        {files.map((file, index) => (\n          <FolderPickerListItem\n            key={file._id}\n            file={file}\n            disabled={isInvalidMoveTarget(entries, file)}\n            onClick={handleClick}\n            showDivider={index !== files.length - 1}\n          />\n        ))}\n      </FolderPickerContentLoader>\n    </List>\n  )\n}\n\nexport { FolderPickerContentNextcloud }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerContentPublic.tsx",
    "content": "import React, { useMemo } from 'react'\n\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport List from 'cozy-ui/transpiled/react/List'\n\nimport { FolderPickerListItem } from './FolderPickerListItem'\n\nimport { FolderPickerAddFolderItem } from '@/components/FolderPicker/FolderPickerAddFolderItem'\nimport { FolderPickerContentLoadMore } from '@/components/FolderPicker/FolderPickerContentLoadMore'\nimport { FolderPickerContentLoader } from '@/components/FolderPicker/FolderPickerContentLoader'\nimport { isInvalidMoveTarget } from '@/components/FolderPicker/helpers'\nimport type { File, FolderPickerEntry } from '@/components/FolderPicker/types'\nimport usePublicFilesQuery from '@/modules/views/Public/usePublicFilesQuery'\n\ninterface FolderPickerContentPublicProps {\n  folder: IOCozyFile\n  isFolderCreationDisplayed: boolean\n  hideFolderCreation: () => void\n  entries: FolderPickerEntry[]\n  navigateTo: (folder: import('./types').File) => void\n  showNextcloudFolder?: boolean\n}\n\nconst FolderPickerContentPublic: React.FC<FolderPickerContentPublicProps> = ({\n  folder,\n  isFolderCreationDisplayed,\n  hideFolderCreation,\n  entries,\n  navigateTo\n}) => {\n  const filesResult = usePublicFilesQuery(folder._id)\n  const {\n    data: filesData,\n    fetchStatus,\n    hasMore,\n    fetchMore\n  } = filesResult as unknown as {\n    fetchStatus: string\n    data?: IOCozyFile[]\n    hasMore: boolean\n    fetchMore: () => void\n  }\n\n  const files: IOCozyFile[] = useMemo(() => {\n    return [...(filesData ?? [])]\n  }, [filesData])\n\n  const handleClick = (file: File): void => {\n    if (isDirectory(file)) {\n      navigateTo(file)\n    }\n  }\n\n  return (\n    <List>\n      <FolderPickerAddFolderItem\n        currentFolderId={folder._id}\n        visible={isFolderCreationDisplayed}\n        afterSubmit={hideFolderCreation}\n        afterAbort={hideFolderCreation}\n      />\n      <FolderPickerContentLoader\n        fetchStatus={fetchStatus}\n        hasNoData={files.length === 0}\n      >\n        {files.map((file, index) => (\n          <FolderPickerListItem\n            key={file._id}\n            file={file}\n            disabled={isInvalidMoveTarget(entries, file)}\n            onClick={handleClick}\n            showDivider={index !== files.length - 1}\n          />\n        ))}\n        <FolderPickerContentLoadMore hasMore={hasMore} fetchMore={fetchMore} />\n      </FolderPickerContentLoader>\n    </List>\n  )\n}\n\nexport { FolderPickerContentPublic }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerContentSharedDrive.tsx",
    "content": "import * as React from 'react'\nimport { useMemo } from 'react'\n\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport type { IOCozyFile } from 'cozy-client/types/types'\nimport List from 'cozy-ui/transpiled/react/List'\n\nimport { FolderPickerListItem } from './FolderPickerListItem'\n\nimport { FolderPickerAddFolderItem } from '@/components/FolderPicker/FolderPickerAddFolderItem'\nimport { FolderPickerContentLoader } from '@/components/FolderPicker/FolderPickerContentLoader'\nimport { isInvalidMoveTarget } from '@/components/FolderPicker/helpers'\nimport type { File, FolderPickerEntry } from '@/components/FolderPicker/types'\nimport { useSharedDriveFolder } from '@/modules/shareddrives/hooks/useSharedDriveFolder'\n\ninterface FolderPickerContentSharedDriveProps {\n  folder: IOCozyFile\n  isFolderCreationDisplayed: boolean\n  hideFolderCreation: () => void\n  entries: FolderPickerEntry[]\n  navigateTo: (folder: import('./types').File) => void\n  showNextcloudFolder?: boolean\n}\n\nconst FolderPickerContentSharedDrive: React.FC<\n  FolderPickerContentSharedDriveProps\n> = ({\n  folder,\n  isFolderCreationDisplayed,\n  hideFolderCreation,\n  entries,\n  navigateTo\n}) => {\n  const driveId = folder.driveId ?? ''\n  const folderId = folder._id\n\n  const { sharedDriveResult } = useSharedDriveFolder({\n    driveId,\n    folderId\n  })\n\n  const { fetchStatus, files } = useMemo(\n    () =>\n      sharedDriveResult.included || sharedDriveResult.data\n        ? { fetchStatus: 'loaded', files: sharedDriveResult.included ?? [] }\n        : { fetchStatus: 'loading', files: [] },\n    [sharedDriveResult]\n  )\n\n  const handleClick = (file: File): void => {\n    if (isDirectory(file)) {\n      navigateTo(file)\n    }\n  }\n\n  return (\n    <List>\n      <FolderPickerAddFolderItem\n        currentFolderId={folder._id}\n        visible={isFolderCreationDisplayed}\n        afterSubmit={hideFolderCreation}\n        afterAbort={hideFolderCreation}\n        driveId={folder.driveId}\n      />\n      <FolderPickerContentLoader\n        fetchStatus={fetchStatus}\n        hasNoData={files.length === 0}\n      >\n        {files.map((file: IOCozyFile, index: number) => (\n          <FolderPickerListItem\n            key={file._id}\n            file={file}\n            disabled={isInvalidMoveTarget(entries, file)}\n            onClick={handleClick}\n            showDivider={index !== files.length - 1}\n          />\n        ))}\n      </FolderPickerContentLoader>\n    </List>\n  )\n}\n\nexport { FolderPickerContentSharedDrive }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerContentSharedDriveRoot.tsx",
    "content": "import React, { useMemo } from 'react'\n\nimport { useQuery } from 'cozy-client'\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport { useSharingContext } from 'cozy-sharing'\nimport List from 'cozy-ui/transpiled/react/List'\n\nimport { FolderPickerListItem } from './FolderPickerListItem'\n\nimport { FolderPickerAddFolderItem } from '@/components/FolderPicker/FolderPickerAddFolderItem'\nimport { FolderPickerContentLoadMore } from '@/components/FolderPicker/FolderPickerContentLoadMore'\nimport { FolderPickerContentLoader } from '@/components/FolderPicker/FolderPickerContentLoader'\nimport { isInvalidMoveTarget } from '@/components/FolderPicker/helpers'\nimport { computeNextcloudRootFolder } from '@/components/FolderPicker/helpers'\nimport type { File, FolderPickerEntry } from '@/components/FolderPicker/types'\nimport { useTransformFolderListHasSharedDriveShortcuts } from '@/hooks/useTransformFolderListHasSharedDriveShortcuts'\nimport { buildMoveOrImportQuery } from '@/queries'\n\ninterface FolderPickerContentSharedDriveRootProps {\n  folder: IOCozyFile\n  isFolderCreationDisplayed: boolean\n  hideFolderCreation: () => void\n  entries: FolderPickerEntry[]\n  navigateTo: (folder: import('./types').File) => void\n  showNextcloudFolder?: boolean\n}\n\nconst FolderPickerContentSharedDriveRoot: React.FC<\n  FolderPickerContentSharedDriveRootProps\n> = ({\n  folder,\n  isFolderCreationDisplayed,\n  hideFolderCreation,\n  entries,\n  navigateTo,\n  showNextcloudFolder\n}) => {\n  const { hasWriteAccess } = useSharingContext() as unknown as {\n    hasWriteAccess: (folderId: string, driveId?: string) => boolean\n  }\n  const contentQuery = buildMoveOrImportQuery(folder._id)\n  const {\n    fetchStatus,\n    data: filesData,\n    hasMore,\n    fetchMore\n  } = useQuery(contentQuery.definition, contentQuery.options) as unknown as {\n    fetchStatus: string\n    data?: IOCozyFile[]\n    hasMore: boolean\n    fetchMore: () => void\n  }\n\n  const { sharedDrives, nonSharedDriveList } =\n    useTransformFolderListHasSharedDriveShortcuts(\n      filesData,\n      showNextcloudFolder\n    ) as {\n      sharedDrives: IOCozyFile[]\n      nonSharedDriveList: IOCozyFile[]\n    }\n\n  const files: IOCozyFile[] = useMemo(() => {\n    return [...sharedDrives, ...nonSharedDriveList]\n  }, [sharedDrives, nonSharedDriveList])\n\n  const handleClick = (file: File): void => {\n    if (isDirectory(file)) {\n      navigateTo(file)\n    }\n\n    if (\n      file._type === 'io.cozy.files' &&\n      file.cozyMetadata?.createdByApp === 'nextcloud' &&\n      file.cozyMetadata.sourceAccount\n    ) {\n      const nextcloudRootFolder = computeNextcloudRootFolder({\n        sourceAccount: file.cozyMetadata.sourceAccount,\n        instanceName: file.metadata.instanceName\n      })\n      navigateTo(nextcloudRootFolder)\n    }\n  }\n\n  return (\n    <List>\n      <FolderPickerAddFolderItem\n        currentFolderId={folder._id}\n        visible={isFolderCreationDisplayed}\n        afterSubmit={hideFolderCreation}\n        afterAbort={hideFolderCreation}\n      />\n      <FolderPickerContentLoader\n        fetchStatus={fetchStatus}\n        hasNoData={files.length === 0}\n      >\n        {files.map((file, index) => (\n          <FolderPickerListItem\n            key={file._id}\n            file={file}\n            disabled={\n              isInvalidMoveTarget(entries, file) ||\n              !hasWriteAccess(file._id, file.driveId)\n            }\n            onClick={handleClick}\n            showDivider={index !== files.length - 1}\n          />\n        ))}\n        <FolderPickerContentLoadMore hasMore={hasMore} fetchMore={fetchMore} />\n      </FolderPickerContentLoader>\n    </List>\n  )\n}\n\nexport { FolderPickerContentSharedDriveRoot }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerFooter.tsx",
    "content": "import React from 'react'\n\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport { useI18n } from 'twake-i18n'\n\nimport { areTargetsInCurrentDir } from '@/components/FolderPicker/helpers'\nimport { File, FolderPickerEntry } from '@/components/FolderPicker/types'\nimport { ROOT_DIR_ID, SHARED_DRIVES_DIR_ID } from '@/constants/config'\n\ninterface FolderPickerFooterProps {\n  onConfirm: (folder: File) => void\n  onClose: () => void | Promise<void>\n  entries: FolderPickerEntry[]\n  folder: File\n  isBusy?: boolean\n  confirmLabel?: string\n  cancelLabel?: string\n  canPickEntriesParentFolder?: boolean\n}\n\n/**\n * List of actions for the move modal\n */\nconst FolderPickerFooter: React.FC<FolderPickerFooterProps> = ({\n  onConfirm,\n  onClose,\n  entries,\n  folder,\n  isBusy = false,\n  confirmLabel,\n  cancelLabel,\n  canPickEntriesParentFolder\n}) => {\n  const { t } = useI18n()\n  const primaryText = confirmLabel ? confirmLabel : t('Move.action')\n  const secondaryText = cancelLabel ? cancelLabel : t('Move.cancel')\n\n  const handleClick = (): void => {\n    onConfirm(folder)\n  }\n\n  const isDisabled =\n    isBusy ||\n    ((folder as IOCozyFile).dir_id === ROOT_DIR_ID &&\n      folder._id === SHARED_DRIVES_DIR_ID) ||\n    (!canPickEntriesParentFolder && areTargetsInCurrentDir(entries, folder))\n\n  return (\n    <>\n      <Buttons variant=\"secondary\" label={secondaryText} onClick={onClose} />\n      <Buttons\n        label={primaryText}\n        onClick={handleClick}\n        disabled={isDisabled}\n        busy={isBusy}\n      />\n    </>\n  )\n}\n\nexport { FolderPickerFooter }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerHeader.spec.js",
    "content": "import { render } from '@testing-library/react'\nimport React from 'react'\n\nimport CozyClient from 'cozy-client'\n\nimport { FolderPickerHeader } from './FolderPickerHeader'\nimport AppLike from 'test/components/AppLike'\n\njest.mock('lib/logger', () => ({\n  error: jest.fn()\n}))\n\ndescribe('FolderPickerHeader', () => {\n  const setupComponent = ({ entries = [], title, subTitle }) => {\n    const props = {\n      entries,\n\n      title,\n      subTitle\n    }\n    const client = new CozyClient({})\n\n    return render(\n      <AppLike client={client}>\n        <FolderPickerHeader {...props} />\n      </AppLike>\n    )\n  }\n  it('should fallback to Move title if no title is given', () => {\n    const { getByText } = setupComponent({\n      entries: [{ file: 1 }, { file: 2 }]\n    })\n    expect(getByText('2 elements'))\n  })\n  it('should display title if title is given and no file', () => {\n    const { getByText } = setupComponent({\n      title: 'My Title'\n    })\n    expect(getByText('My Title'))\n  })\n  it('should display the right title if only one io.cozy.files', () => {\n    const { getByText } = setupComponent({\n      title: 'My Title',\n      entries: [\n        {\n          name: 'FileName.txt',\n          class: 'file'\n        }\n      ]\n    })\n    expect(getByText('FileName.txt'))\n  })\n\n  it('should display the right title if it comes from the outside', () => {\n    const { getByText } = setupComponent({\n      title: 'My Title',\n      entries: [\n        {\n          name: 'FileName.txt'\n        }\n      ]\n    })\n    expect(getByText('FileName.txt'))\n  })\n  it('should display the right title if more than one files', () => {\n    const { getByText } = setupComponent({\n      title: 'My Title',\n      entries: [\n        {\n          name: 'FileName.txt'\n        },\n        {\n          name: 'FileName2.txt'\n        }\n      ]\n    })\n    expect(getByText('My Title'))\n  })\n})\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerHeader.tsx",
    "content": "import React from 'react'\n\nimport Box from 'cozy-ui/transpiled/react/Box'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport { useI18n } from 'twake-i18n'\n\nimport { FolderPickerHeaderIllustration } from '@/components/FolderPicker/FolderPickerHeaderIllustration'\nimport { FolderPickerEntry } from '@/components/FolderPicker/types'\n\ninterface FolderPickerHeaderProps {\n  entries: FolderPickerEntry[]\n  title?: string\n  subTitle?: string\n}\n\nconst specificCardStyle: React.CSSProperties = {\n  marginLeft: '2rem',\n  marginRight: '4rem',\n  marginTop: '1rem',\n  marginBottom: '1rem',\n  background: 'var(--contrastBackgroundColor)',\n  display: 'flex',\n  alignItems: 'center',\n  gap: '1rem'\n}\n\nconst FolderPickerHeader: React.FC<FolderPickerHeaderProps> = ({\n  entries,\n  title,\n  subTitle\n}) => {\n  const { t } = useI18n()\n  const titleToUse = title\n    ? title\n    : t('Move.title', { smart_count: entries.length })\n  const subTitleToUse = subTitle ? subTitle : t('Move.to')\n\n  return (\n    <Box\n      display=\"block\"\n      border={1}\n      borderColor=\"var(--dividerColor)\"\n      borderRadius={8}\n      padding={2}\n      className=\"u-m-half-s u-mv-1 u-mh-2\"\n      style={specificCardStyle}\n    >\n      <FolderPickerHeaderIllustration entries={entries} />\n      <div className=\"u-ellipsis\">\n        <Typography variant=\"h6\" noWrap>\n          {entries.length !== 1 ? titleToUse : entries[0].name}\n        </Typography>\n        <Typography variant=\"caption\" color=\"textSecondary\" noWrap>\n          {subTitleToUse}\n        </Typography>\n      </div>\n    </Box>\n  )\n}\n\nexport { FolderPickerHeader }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerHeaderIllustration.tsx",
    "content": "import React from 'react'\n\nimport Avatar from 'cozy-ui/transpiled/react/Avatar'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport DriveIcon from 'cozy-ui/transpiled/react/Icons/FileTypeFolder'\n\nimport { FolderPickerEntry } from '@/components/FolderPicker/types'\nimport getMimeTypeIcon from '@/lib/getMimeTypeIcon'\nimport FileThumbnail from '@/modules/filelist/icons/FileThumbnail'\n\ninterface FolderPickerHeaderIllustrationProps {\n  entries: FolderPickerEntry[]\n}\n\nconst FolderPickerHeaderIllustration: React.FC<\n  FolderPickerHeaderIllustrationProps\n> = ({ entries }) => {\n  if (entries.length === 1) {\n    const firstItem = entries[0]\n\n    // this is a cozy files\n    if (firstItem.class) {\n      return <FileThumbnail file={firstItem} isInSyncFromSharing={false} />\n    }\n\n    // this is a cozy-flagship file, doesn't have a class yet\n    if (firstItem.name && firstItem.mime) {\n      return (\n        <Icon\n          icon={getMimeTypeIcon(false, firstItem.name, firstItem.mime)}\n          size={32}\n        />\n      )\n    }\n\n    return <Icon icon={DriveIcon} size={32} />\n  }\n  if (entries.length > 1) {\n    return (\n      <Avatar color=\"var(--dodgerBlue)\" textColor=\"var(--white)\">\n        <span>{entries.length > 99 ? '99+' : entries.length}</span>\n      </Avatar>\n    )\n  }\n  return null\n}\n\nexport { FolderPickerHeaderIllustration }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerListItem.tsx",
    "content": "import { filesize } from 'filesize'\nimport React, { FC } from 'react'\n\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport Divider from 'cozy-ui/transpiled/react/Divider'\nimport ListItem from 'cozy-ui/transpiled/react/ListItem'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/folder-picker.styl'\n\nimport type { File } from '@/components/FolderPicker/types'\nimport { getFileNameAndExtension } from '@/modules/filelist/helpers'\nimport FileThumbnail from '@/modules/filelist/icons/FileThumbnail'\n\ninterface FolderPickerListItemProps {\n  file: File\n  disabled?: boolean\n  onClick: (file: File) => void\n  showDivider?: boolean\n}\n\nconst FolderPickerListItem: FC<FolderPickerListItemProps> = ({\n  file,\n  disabled = false,\n  onClick,\n  showDivider = false\n}) => {\n  const { f, t } = useI18n()\n  const { isMobile } = useBreakpoints()\n  const gutters = isMobile ? 'default' : 'double'\n\n  const handleClick = (): void => {\n    onClick(file)\n  }\n\n  const formattedUpdatedAt = f(\n    new Date(file.updated_at),\n    t('table.row_update_format')\n  )\n  const formattedSize = file.size\n    ? filesize(file.size, { base: 10 })\n    : undefined\n  const secondaryText = !isDirectory(file)\n    ? `${formattedUpdatedAt}${formattedSize ? ` - ${formattedSize}` : ''}`\n    : undefined\n\n  const { title } = getFileNameAndExtension(file, t)\n\n  return (\n    <>\n      <ListItem\n        button\n        onClick={handleClick}\n        disabled={disabled}\n        gutters={gutters}\n      >\n        <ListItemIcon className=\"u-pos-relative\">\n          <FileThumbnail\n            file={file}\n            showSharedBadge\n            componentsProps={{\n              sharedBadge: {\n                className: styles['icon-shared']\n              }\n            }}\n          />\n        </ListItemIcon>\n        <ListItemText primary={title} secondary={secondaryText} />\n      </ListItem>\n      {showDivider && <Divider />}\n    </>\n  )\n}\n\nexport { FolderPickerListItem }\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerTopbar.spec.jsx",
    "content": "import { fireEvent, render, screen, waitFor } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\n\nimport AppLike from 'test/components/AppLike'\n\nimport { FolderPickerTopbar } from '@/components/FolderPicker/FolderPickerTopbar'\n\njest.mock('@/modules/nextcloud/hooks/useNextcloudInfos', () => ({\n  useNextcloudInfos: () => ({\n    isLoading: false,\n    instanceName: 'Cozycloud',\n    instanceUrl: 'https://cozycloud.example.com',\n    rootFolderName: 'Cozycloud (Nextcloud)'\n  })\n}))\n\ndescribe('FolderPickerTopbar', () => {\n  const navigateTo = jest.fn()\n  const showFolderCreation = jest.fn()\n\n  const cozyFolder = {\n    _id: '123',\n    _type: 'io.cozy.files',\n    type: 'directory',\n    dir_id: 'io.cozy.files.root-dir',\n    name: 'Photos'\n  }\n\n  const rootCozyFolder = {\n    id: 'io.cozy.files.root-dir',\n    _id: 'io.cozy.files.root-dir',\n    _type: 'io.cozy.files',\n    type: 'directory'\n  }\n\n  const sharedDrivesFolder = {\n    id: 'io.cozy.files.shared-drives-dir',\n    _id: 'io.cozy.files.shared-drives-dir',\n    _type: 'io.cozy.files',\n    type: 'directory',\n    dir_id: 'io.cozy.files.root-dir'\n  }\n\n  const nextcloudFolder = {\n    id: '123',\n    _id: '123',\n    _type: 'io.cozy.remote.nextcloud.files',\n    path: '/Documents',\n    parentPath: '/',\n    name: 'Documents',\n    cozyMetadata: {\n      sourceAccount: '123'\n    },\n    links: {\n      self: 'unknown'\n    },\n    size: 0,\n    type: 'directory',\n    updated_at: expect.any(String)\n  }\n\n  const rootNextcloudFolder = {\n    id: 'io.cozy.remote.nextcloud.files.root-dir',\n    _id: 'io.cozy.remote.nextcloud.files.root-dir',\n    _type: 'io.cozy.remote.nextcloud.files',\n    path: '/',\n    parentPath: '',\n    name: 'Cozycloud (Nextcloud)',\n    cozyMetadata: {\n      sourceAccount: '123'\n    },\n    links: {\n      self: 'unknown'\n    },\n    size: 0,\n    type: 'directory',\n    updated_at: expect.any(String)\n  }\n\n  const setup = ({\n    canCreateFolder = false,\n    folder,\n    showFolderCreation: showFolderCreationProp\n  } = {}) => {\n    const mockClient = createMockClient({\n      queries: {\n        'io.cozy.files/io.cozy.files.root-dir': {\n          doctype: 'io.cozy.files',\n          definition: {\n            doctype: 'io.cozy.files',\n            id: 'io.cozy.files.root-dir'\n          },\n          data: [rootCozyFolder]\n        },\n        'io.cozy.files/io.cozy.files.shared-drives-dir': {\n          doctype: 'io.cozy.files',\n          definition: {\n            doctype: 'io.cozy.files',\n            id: 'io.cozy.files.shared-drives-dir'\n          },\n          data: [sharedDrivesFolder]\n        },\n        'io.cozy.remote.nextcloud.files/sourceAccount/123/path/': {\n          doctype: 'io.cozy.remote.nextcloud.files',\n          data: [nextcloudFolder]\n        }\n      }\n    })\n\n    return render(\n      <AppLike client={mockClient}>\n        <FolderPickerTopbar\n          navigateTo={navigateTo}\n          folder={folder}\n          canCreateFolder={canCreateFolder}\n          showFolderCreation={showFolderCreationProp}\n        />\n      </AppLike>\n    )\n  }\n\n  it('should hide back button for the root cozy folder', () => {\n    setup({ folder: rootCozyFolder })\n\n    expect(screen.getByText('Files')).toBeInTheDocument()\n\n    const backButton = screen.queryByRole('button', {\n      name: 'Back'\n    })\n    expect(backButton).toBeNull()\n  })\n\n  it('should show back button for a cozy folder', async () => {\n    setup({ folder: cozyFolder })\n\n    expect(screen.getByText('Photos')).toBeInTheDocument()\n\n    const backButton = screen.getByRole('button', {\n      name: 'Back'\n    })\n    fireEvent.click(backButton)\n    await waitFor(() => {\n      expect(navigateTo).toHaveBeenCalledWith(rootCozyFolder)\n    })\n  })\n\n  it('should show back button for a nextcloud folder', async () => {\n    setup({ folder: nextcloudFolder })\n\n    expect(screen.getByText('Documents')).toBeInTheDocument()\n\n    const backButton = screen.getByRole('button', {\n      name: 'Back'\n    })\n    fireEvent.click(backButton)\n    await waitFor(() => {\n      expect(navigateTo).toHaveBeenCalledWith(rootNextcloudFolder)\n    })\n  })\n\n  it('should show back button inside a deep nextcloud folder', async () => {\n    setup({\n      folder: {\n        _id: '123',\n        _type: 'io.cozy.remote.nextcloud.files',\n        path: '/Documents/Invoices',\n        parentPath: '/Documents',\n        name: 'Invoices',\n        cozyMetadata: {\n          sourceAccount: '123'\n        }\n      }\n    })\n\n    expect(screen.getByText('Invoices')).toBeInTheDocument()\n\n    const backButton = screen.getByRole('button', {\n      name: 'Back'\n    })\n    fireEvent.click(backButton)\n    await waitFor(() => {\n      expect(navigateTo).toHaveBeenCalledWith(\n        expect.objectContaining({\n          _id: '123',\n          _type: 'io.cozy.remote.nextcloud.files',\n          path: '/Documents',\n          parentPath: '/',\n          name: 'Documents',\n          cozyMetadata: {\n            sourceAccount: '123'\n          },\n          id: '123'\n        })\n      )\n    })\n  })\n\n  it('should show back button for a root nextcloud folder', async () => {\n    setup({ folder: rootNextcloudFolder })\n\n    expect(screen.getByText('Cozycloud (Nextcloud)')).toBeInTheDocument()\n\n    const backButton = screen.getByRole('button', {\n      name: 'Back'\n    })\n    fireEvent.click(backButton)\n    await waitFor(() => {\n      expect(navigateTo).toHaveBeenCalledWith(sharedDrivesFolder)\n    })\n  })\n\n  it('should show create folder button when canCreateFolder and inside cozy folder', async () => {\n    setup({ canCreateFolder: true, folder: cozyFolder, showFolderCreation })\n\n    const addFolderButton = screen.getByRole('button', {\n      name: 'Add a folder'\n    })\n    fireEvent.click(addFolderButton)\n    await waitFor(() => {\n      expect(showFolderCreation).toHaveBeenCalled()\n    })\n  })\n\n  it('should hide create folder button when canCreateFolder is false', () => {\n    setup({ canCreateFolder: false, folder: cozyFolder })\n\n    const addFolderButton = screen.queryByRole('button', {\n      name: 'Add a folder'\n    })\n    expect(addFolderButton).toBeNull()\n  })\n\n  it('should hide create folder button when canCreateFolder is true but its inside Nextcloud folder', () => {\n    setup({\n      canCreateFolder: true,\n      folder: nextcloudFolder\n    })\n\n    const addFolderButton = screen.queryByRole('button', {\n      name: 'Add a folder'\n    })\n    expect(addFolderButton).toBeNull()\n  })\n})\n"
  },
  {
    "path": "src/components/FolderPicker/FolderPickerTopbar.tsx",
    "content": "import cx from 'classnames'\nimport React, { useCallback, useState } from 'react'\n\nimport { useClient } from 'cozy-client'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport FolderAddIcon from 'cozy-ui/transpiled/react/Icons/FolderAdd'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { getParentFolder } from './helpers'\n\nimport BackButton from '@/components/Button/BackButton'\nimport { File } from '@/components/FolderPicker/types'\nimport { ROOT_DIR_ID } from '@/constants/config'\nimport { useNextcloudInfos } from '@/modules/nextcloud/hooks/useNextcloudInfos'\n\ninterface FolderPickerTopbarProps {\n  navigateTo: (folder: import('./types').File) => void\n  folder: File\n  showFolderCreation?: () => void\n  canCreateFolder?: boolean\n}\n\nconst FolderPickerTopbar: React.FC<FolderPickerTopbarProps> = ({\n  navigateTo,\n  folder,\n  showFolderCreation,\n  canCreateFolder\n}) => {\n  const { t } = useI18n()\n  const client = useClient()\n  const { isMobile } = useBreakpoints()\n  const [isNavigating, setNavigating] = useState(false)\n\n  const showBackButton = folder._id !== ROOT_DIR_ID\n\n  const { instanceName } = useNextcloudInfos({\n    sourceAccount: folder.cozyMetadata?.sourceAccount\n  })\n\n  const handleNavigateTo = useCallback(async () => {\n    setNavigating(true)\n    const parentFolder = await getParentFolder(client, folder, {\n      instanceName\n    })\n    navigateTo(parentFolder)\n    setNavigating(false)\n  }, [client, folder, navigateTo, instanceName])\n\n  const name =\n    folder._id === ROOT_DIR_ID ? t('breadcrumb.title_drive') : folder.name\n\n  const showCreateFolderButton =\n    canCreateFolder && folder._type !== 'io.cozy.remote.nextcloud.files'\n\n  return (\n    <div\n      style={{ height: '3rem' }}\n      className={cx('u-flex u-flex-items-center', !isMobile ? 'u-mh-1' : '')}\n    >\n      {showBackButton ? (\n        <BackButton onClick={handleNavigateTo} disabled={isNavigating} />\n      ) : null}\n      <Typography variant=\"h4\" className={!showBackButton ? 'u-ml-1' : ''}>\n        {name}\n      </Typography>\n      {showCreateFolderButton ? (\n        <IconButton\n          className=\"u-ml-auto\"\n          onClick={showFolderCreation}\n          aria-label={t('Move.addFolder')}\n        >\n          <Icon icon={FolderAddIcon} />\n        </IconButton>\n      ) : null}\n    </div>\n  )\n}\n\nexport { FolderPickerTopbar }\n"
  },
  {
    "path": "src/components/FolderPicker/helpers.spec.js",
    "content": "import { areTargetsInCurrentDir } from './helpers'\n\ndescribe('areTargetsInCurrentDir', () => {\n  it('should return false if the current folder is undefined', () => {\n    const targets = [\n      { _id: 'folder1', path: '/folder1' },\n      { _id: 'folder2', path: '/folder2' }\n    ]\n    const folder = undefined\n\n    expect(areTargetsInCurrentDir(targets, folder)).toBe(false)\n  })\n\n  it('should return true if all targets are in the current folder', () => {\n    const targets = [\n      { _id: 'folder1', path: '/folder1', dir_id: 'currentFolder' },\n      { _id: 'folder2', path: '/folder2', dir_id: 'currentFolder' }\n    ]\n    const folder = { _id: 'currentFolder', path: '/currentFolder' }\n\n    expect(areTargetsInCurrentDir(targets, folder)).toBe(true)\n  })\n\n  it('should return false if not all targets are in the current folder', () => {\n    const targets = [\n      { _id: 'folder1', path: '/folder1', dir_id: 'currentFolder' },\n      { _id: 'folder2', path: '/folder2', dir_id: 'otherFolder' }\n    ]\n    const folder = { _id: 'currentFolder', path: '/currentFolder' }\n\n    expect(areTargetsInCurrentDir(targets, folder)).toBe(false)\n  })\n\n  it('should return true if all targets are in the root folder', () => {\n    const targets = [\n      { _id: 'folder1', path: '/folder1' },\n      { _id: 'file1', path: '/file1.png' }\n    ]\n    const folder = { _id: 'io.cozy.files.root-dir', path: '/' }\n\n    expect(areTargetsInCurrentDir(targets, folder)).toBe(true)\n  })\n\n  it('should return true if all targets are in a subfolder', () => {\n    const targets = [\n      { _id: 'folder3', path: '/folder1/folder2/folder3' },\n      { _id: 'file1', path: '/folder1/folder2/file1.png' }\n    ]\n    const folder = { _id: 'folder2', path: '/folder1/folder2' }\n\n    expect(areTargetsInCurrentDir(targets, folder)).toBe(true)\n  })\n\n  it('should return false if all targets deeper inside subfolders', () => {\n    const targets = [\n      { _id: 'folder3', path: '/folder1/folder2/folder3' },\n      { _id: 'file1', path: '/folder1/folder2/file1.png' }\n    ]\n    const folder = { _id: 'folder1', path: '/folder1' }\n\n    expect(areTargetsInCurrentDir(targets, folder)).toBe(false)\n  })\n})\n"
  },
  {
    "path": "src/components/FolderPicker/helpers.ts",
    "content": "import CozyClient from 'cozy-client/types/CozyClient'\nimport { IOCozyFile, NextcloudFile } from 'cozy-client/types/types'\n\nimport { FolderPickerEntry, File } from '@/components/FolderPicker/types'\nimport { SHARED_DRIVES_DIR_ID } from '@/constants/config'\nimport { getParentPath } from '@/lib/path'\nimport {\n  buildFileOrFolderByIdQuery,\n  buildNextcloudFolderQuery,\n  buildSharedDriveFileOrFolderByIdQuery\n} from '@/queries'\n\n/**\n * Checks if the target is an invalid move target based on the subjects and target provided.\n *\n * @param subjects - The array of subjects to check against.\n * @param target - The target object to check.\n * @returns - Returns true if the target is an invalid move target, otherwise false.\n */\nexport const isInvalidMoveTarget = (\n  subjects: FolderPickerEntry[],\n  target: File\n): boolean => {\n  const isASubject = subjects.find(subject => subject._id === target._id)\n  const isAFile =\n    target.type === 'file' &&\n    (target._type === 'io.cozy.remote.nextcloud.files' ||\n      target.cozyMetadata?.createdByApp !== 'nextcloud')\n  return isAFile || isASubject !== undefined\n}\n\n/**\n * Returns whether one of the targeted folders is part of the current folder\n *\n * @param targets - List of folders\n * @param folder - The id of the current folder\n * @returns - Whether one of the targeted folders is part of the current folder\n */\nexport const areTargetsInCurrentDir = (\n  targets: FolderPickerEntry[],\n  folder?: File\n): boolean => {\n  if (!folder) return false\n\n  return targets.every(target => {\n    if (target.dir_id) {\n      return target.dir_id === folder._id\n    }\n    if (target._type === folder._type && target.path) {\n      return getParentPath(target.path) === folder.path\n    }\n    return false\n  })\n}\n\n/**\n * Retrieves the parent folder of a given file from the Cozy client.\n *\n * @param client - The Cozy client instance.\n * @param folder - The file for which to retrieve the parent folder.\n * @returns A promise that resolves to the parent folder of the given file, or undefined if not found.\n */\nconst getCozyParentFolder = async (\n  client: CozyClient | null,\n  id: string,\n  driveId?: string\n): Promise<IOCozyFile> => {\n  const parentFolderQuery = driveId\n    ? buildSharedDriveFileOrFolderByIdQuery({ fileId: id, driveId })\n    : buildFileOrFolderByIdQuery(id)\n  const parentFolder = (await client?.fetchQueryAndGetFromState({\n    definition: parentFolderQuery.definition(),\n    options: parentFolderQuery.options\n  })) as {\n    data?: IOCozyFile\n  }\n\n  if (!parentFolder.data) {\n    throw new Error('Parent folder not found')\n  }\n\n  return parentFolder.data\n}\n\n/**\n * Returns the root folder object for Nextcloud.\n *\n * @param options - The options for getting the root folder.\n * @param options.sourceAccount - The id of account that n.\n * @param options.instanceName - The instance name of the Nextcloud server.\n * @returns - The root folder object.\n */\nexport const computeNextcloudRootFolder = ({\n  sourceAccount,\n  instanceName\n}: {\n  sourceAccount: string\n  instanceName?: string\n}): NextcloudFile => ({\n  id: 'io.cozy.remote.nextcloud.files.root-dir',\n  _id: 'io.cozy.remote.nextcloud.files.root-dir',\n  _type: 'io.cozy.remote.nextcloud.files',\n  name: `${instanceName ?? ''} (Nextcloud)`,\n  path: '/',\n  parentPath: '',\n  cozyMetadata: {\n    sourceAccount: sourceAccount\n  },\n  type: 'directory',\n  links: {\n    self: 'unknown'\n  },\n  size: 0,\n  updated_at: new Date().toISOString()\n})\n\n/**\n * Retrieves the parent folder of a given Nextcloud file.\n *\n * @param client - The CozyClient instance used to fetch the parent folder.\n * @param folder - The Nextcloud file for which to retrieve the parent folder.\n * @returns A Promise that resolves to the parent folder of the given Nextcloud file, or undefined if not found.\n */\nconst getNextcloudParentFolder = async (\n  client: CozyClient | null,\n  folder: NextcloudFile\n): Promise<NextcloudFile> => {\n  const parentFolderQuery = buildNextcloudFolderQuery({\n    sourceAccount: folder.cozyMetadata.sourceAccount,\n    path: getParentPath(folder.parentPath) ?? 'unknown'\n  })\n  const parentFolderResult = (await client?.fetchQueryAndGetFromState({\n    definition: parentFolderQuery.definition(),\n    options: parentFolderQuery.options\n  })) as {\n    data?: NextcloudFile[]\n  }\n  const parentFolder = (parentFolderResult.data ?? []).find(\n    file => file.path === folder.parentPath\n  )\n\n  if (!parentFolder) {\n    throw new Error('Parent folder not found')\n  }\n\n  return parentFolder\n}\n\n/**\n * Retrieves the parent folder of a given file.\n *\n * @param client - The CozyClient instance.\n * @param folder - The file for which to retrieve the parent folder.\n * @param instanceName - (Optional) The name of the Cozy instance.\n * @returns A Promise that resolves to the parent folder of the given file, or undefined if the file is the root folder.\n */\nexport const getParentFolder = async (\n  client: CozyClient | null,\n  folder: File,\n  { instanceName }: { instanceName?: string }\n): Promise<File> => {\n  if (folder._type === 'io.cozy.remote.nextcloud.files') {\n    if (folder.path === '/') {\n      return await getCozyParentFolder(client, SHARED_DRIVES_DIR_ID)\n    }\n    if (folder.parentPath === '/') {\n      return computeNextcloudRootFolder({\n        sourceAccount: folder.cozyMetadata.sourceAccount,\n        instanceName\n      })\n    } else {\n      return await getNextcloudParentFolder(client, folder)\n    }\n  }\n\n  const driveId = folder.dir_id === SHARED_DRIVES_DIR_ID ? '' : folder.driveId\n  return await getCozyParentFolder(client, folder.dir_id, driveId)\n}\n"
  },
  {
    "path": "src/components/FolderPicker/types.ts",
    "content": "import { IOCozyFile, NextcloudFile } from 'cozy-client/types/types'\n\nexport type File = IOCozyFile | NextcloudFile\n\nexport interface FolderPickerEntry {\n  _id?: string\n  _type: string\n  type: string\n  name: string\n  mime: string\n  dir_id?: string\n  class?: string\n  path?: string\n  driveId?: string\n}\n"
  },
  {
    "path": "src/components/IconPicker/IconColorPicker.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport ClickAwayListener from 'cozy-ui/transpiled/react/ClickAwayListener'\nimport GridList from 'cozy-ui/transpiled/react/GridList'\nimport GridListTile from 'cozy-ui/transpiled/react/GridListTile'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport Paper from 'cozy-ui/transpiled/react/Paper'\nimport Popper from 'cozy-ui/transpiled/react/Popper'\n\nimport { getIcon } from './IconIndex'\nimport { ICON_COLORS } from './constants'\n\nimport styles from '@/styles/folder-customizer.styl'\n\nexport const IconColorPicker = ({\n  anchorEl,\n  selectedIcon,\n  iconSize,\n  isMobile,\n  onPickColor,\n  onClose\n}) => {\n  return (\n    <Popper\n      open={true}\n      anchorEl={anchorEl}\n      placement=\"bottom\"\n      disablePortal={false}\n      className={styles.iconColorPopper}\n    >\n      <ClickAwayListener onClickAway={onClose} mouseEvent=\"onMouseDown\">\n        <Paper className={`u-p-half u-dib ${styles.iconColorPaper}`}>\n          <GridList cols={6} cellHeight={isMobile ? 34 : 30}>\n            {ICON_COLORS.map(color => (\n              <GridListTile key={color} className=\"u-ta-center\">\n                <IconButton\n                  className=\"u-p-0\"\n                  onClick={() => onPickColor(color)}\n                >\n                  <Icon\n                    size={iconSize}\n                    icon={getIcon(selectedIcon)}\n                    color={color}\n                  />\n                </IconButton>\n              </GridListTile>\n            ))}\n          </GridList>\n        </Paper>\n      </ClickAwayListener>\n    </Popper>\n  )\n}\n\nIconColorPicker.propTypes = {\n  anchorEl: PropTypes.any,\n  selectedIcon: PropTypes.string,\n  iconSize: PropTypes.number,\n  isMobile: PropTypes.bool,\n  onPickColor: PropTypes.func,\n  onClose: PropTypes.func\n}\n\nIconColorPicker.displayName = 'IconColorPicker'\n"
  },
  {
    "path": "src/components/IconPicker/IconIndex.js",
    "content": "import Album from 'cozy-ui/transpiled/react/Icons/Album'\nimport AlbumAdd from 'cozy-ui/transpiled/react/Icons/AlbumAdd'\nimport AlbumRemove from 'cozy-ui/transpiled/react/Icons/AlbumRemove'\nimport Answer from 'cozy-ui/transpiled/react/Icons/Answer'\nimport Apple from 'cozy-ui/transpiled/react/Icons/Apple'\nimport Archive from 'cozy-ui/transpiled/react/Icons/Archive'\nimport ArrowUp from 'cozy-ui/transpiled/react/Icons/ArrowUp'\nimport AssignAdmin from 'cozy-ui/transpiled/react/Icons/AssignAdmin'\nimport AssignModerator from 'cozy-ui/transpiled/react/Icons/AssignModerator'\nimport Assistant from 'cozy-ui/transpiled/react/Icons/Assistant'\nimport Attachment from 'cozy-ui/transpiled/react/Icons/Attachment'\nimport Attention from 'cozy-ui/transpiled/react/Icons/Attention'\nimport Bank from 'cozy-ui/transpiled/react/Icons/Bank'\nimport BankCheck from 'cozy-ui/transpiled/react/Icons/BankCheck'\nimport Banking from 'cozy-ui/transpiled/react/Icons/Banking'\nimport BankingAdd from 'cozy-ui/transpiled/react/Icons/BankingAdd'\nimport Bell from 'cozy-ui/transpiled/react/Icons/Bell'\nimport Benefit from 'cozy-ui/transpiled/react/Icons/Benefit'\nimport Bike from 'cozy-ui/transpiled/react/Icons/Bike'\nimport Bill from 'cozy-ui/transpiled/react/Icons/Bill'\nimport Bottom from 'cozy-ui/transpiled/react/Icons/Bottom'\nimport BrowserBrave from 'cozy-ui/transpiled/react/Icons/BrowserBrave'\nimport BrowserChrome from 'cozy-ui/transpiled/react/Icons/BrowserChrome'\nimport BrowserDuckduckgo from 'cozy-ui/transpiled/react/Icons/BrowserDuckduckgo'\nimport BrowserEdge from 'cozy-ui/transpiled/react/Icons/BrowserEdge'\nimport BrowserEdgeChromium from 'cozy-ui/transpiled/react/Icons/BrowserEdgeChromium'\nimport BrowserFirefox from 'cozy-ui/transpiled/react/Icons/BrowserFirefox'\nimport BrowserIe from 'cozy-ui/transpiled/react/Icons/BrowserIe'\nimport BrowserOpera from 'cozy-ui/transpiled/react/Icons/BrowserOpera'\nimport BrowserSafari from 'cozy-ui/transpiled/react/Icons/BrowserSafari'\nimport Burger from 'cozy-ui/transpiled/react/Icons/Burger'\nimport Bus from 'cozy-ui/transpiled/react/Icons/Bus'\nimport Calendar from 'cozy-ui/transpiled/react/Icons/Calendar'\nimport Camera from 'cozy-ui/transpiled/react/Icons/Camera'\nimport Car from 'cozy-ui/transpiled/react/Icons/Car'\nimport CarbonCopy from 'cozy-ui/transpiled/react/Icons/CarbonCopy'\nimport CarPooling from 'cozy-ui/transpiled/react/Icons/Carpooling'\nimport Categories from 'cozy-ui/transpiled/react/Icons/Categories'\nimport Certified from 'cozy-ui/transpiled/react/Icons/Certified'\nimport Check from 'cozy-ui/transpiled/react/Icons/Check'\nimport CheckCircle from 'cozy-ui/transpiled/react/Icons/CheckCircle'\nimport CheckList from 'cozy-ui/transpiled/react/Icons/CheckList'\nimport CheckSquare from 'cozy-ui/transpiled/react/Icons/CheckSquare'\nimport Checkbox from 'cozy-ui/transpiled/react/Icons/Checkbox'\nimport Chess from 'cozy-ui/transpiled/react/Icons/Chess'\nimport Child from 'cozy-ui/transpiled/react/Icons/Child'\nimport CircleFilled from 'cozy-ui/transpiled/react/Icons/CircleFilled'\nimport Clock from 'cozy-ui/transpiled/react/Icons/Clock'\nimport ClockOutline from 'cozy-ui/transpiled/react/Icons/ClockOutline'\nimport Cloud from 'cozy-ui/transpiled/react/Icons/Cloud'\nimport Cloud2 from 'cozy-ui/transpiled/react/Icons/Cloud2'\nimport CloudHappy from 'cozy-ui/transpiled/react/Icons/CloudHappy'\nimport CloudPlusOutlined from 'cozy-ui/transpiled/react/Icons/CloudPlusOutlined'\nimport Cocktail from 'cozy-ui/transpiled/react/Icons/Cocktail'\nimport Collect from 'cozy-ui/transpiled/react/Icons/Collect'\nimport Comment from 'cozy-ui/transpiled/react/Icons/Comment'\nimport Company from 'cozy-ui/transpiled/react/Icons/Company'\nimport Compare from 'cozy-ui/transpiled/react/Icons/Compare'\nimport Compass from 'cozy-ui/transpiled/react/Icons/Compass'\nimport Connector from 'cozy-ui/transpiled/react/Icons/Connector'\nimport Contract from 'cozy-ui/transpiled/react/Icons/Contract'\nimport Contrast from 'cozy-ui/transpiled/react/Icons/Contrast'\nimport Copy from 'cozy-ui/transpiled/react/Icons/Copy'\nimport CozyCircle from 'cozy-ui/transpiled/react/Icons/CozyCircle'\nimport CozyLaugh from 'cozy-ui/transpiled/react/Icons/CozyLaugh'\nimport CozyLock from 'cozy-ui/transpiled/react/Icons/CozyLock'\nimport CozyText from 'cozy-ui/transpiled/react/Icons/CozyText'\nimport Credit from 'cozy-ui/transpiled/react/Icons/Credit'\nimport CreditCard from 'cozy-ui/transpiled/react/Icons/CreditCard'\nimport CreditCardAdd from 'cozy-ui/transpiled/react/Icons/CreditCardAdd'\nimport Crop from 'cozy-ui/transpiled/react/Icons/Crop'\nimport Cross from 'cozy-ui/transpiled/react/Icons/Cross'\nimport CrossCircle from 'cozy-ui/transpiled/react/Icons/CrossCircle'\nimport CrossCircleOutline from 'cozy-ui/transpiled/react/Icons/CrossCircleOutline'\nimport CrossMedium from 'cozy-ui/transpiled/react/Icons/CrossMedium'\nimport CrossSmall from 'cozy-ui/transpiled/react/Icons/CrossSmall'\nimport Cube from 'cozy-ui/transpiled/react/Icons/Cube'\nimport Dash from 'cozy-ui/transpiled/react/Icons/Dash'\nimport Dashboard from 'cozy-ui/transpiled/react/Icons/Dashboard'\nimport DataControl from 'cozy-ui/transpiled/react/Icons/DataControl'\nimport Debit from 'cozy-ui/transpiled/react/Icons/Debit'\nimport DesktopDownload from 'cozy-ui/transpiled/react/Icons/DesktopDownload'\nimport Devices from 'cozy-ui/transpiled/react/Icons/Devices'\nimport Discuss from 'cozy-ui/transpiled/react/Icons/Discuss'\nimport Dots from 'cozy-ui/transpiled/react/Icons/Dots'\nimport Down from 'cozy-ui/transpiled/react/Icons/Down'\nimport Download from 'cozy-ui/transpiled/react/Icons/Download'\nimport DrawingArrowUp from 'cozy-ui/transpiled/react/Icons/DrawingArrowUp'\nimport Dropdown from 'cozy-ui/transpiled/react/Icons/Dropdown'\nimport DropdownClose from 'cozy-ui/transpiled/react/Icons/DropdownClose'\nimport DropdownOpen from 'cozy-ui/transpiled/react/Icons/DropdownOpen'\nimport Dropup from 'cozy-ui/transpiled/react/Icons/Dropup'\nimport ElectricBike from 'cozy-ui/transpiled/react/Icons/ElectricBike'\nimport ElectricCar from 'cozy-ui/transpiled/react/Icons/ElectricCar'\nimport ElectricScooter from 'cozy-ui/transpiled/react/Icons/ElectricScooter'\nimport Email from 'cozy-ui/transpiled/react/Icons/Email'\nimport EmailNotification from 'cozy-ui/transpiled/react/Icons/EmailNotification'\nimport EmailOpen from 'cozy-ui/transpiled/react/Icons/EmailOpen'\nimport Eu from 'cozy-ui/transpiled/react/Icons/Eu'\nimport Euro from 'cozy-ui/transpiled/react/Icons/Euro'\nimport Exchange from 'cozy-ui/transpiled/react/Icons/Exchange'\nimport Eye from 'cozy-ui/transpiled/react/Icons/Eye'\nimport EyeClosed from 'cozy-ui/transpiled/react/Icons/EyeClosed'\nimport FaceId from 'cozy-ui/transpiled/react/Icons/FaceId'\nimport File from 'cozy-ui/transpiled/react/Icons/File'\nimport FileAdd from 'cozy-ui/transpiled/react/Icons/FileAdd'\nimport FileDuotone from 'cozy-ui/transpiled/react/Icons/FileDuotone'\nimport FileNew from 'cozy-ui/transpiled/react/Icons/FileNew'\nimport FileNone from 'cozy-ui/transpiled/react/Icons/FileNone'\nimport FileOutline from 'cozy-ui/transpiled/react/Icons/FileOutline'\nimport Filter from 'cozy-ui/transpiled/react/Icons/Filter'\nimport Fingerprint from 'cozy-ui/transpiled/react/Icons/Fingerprint'\nimport Fitness from 'cozy-ui/transpiled/react/Icons/Fitness'\nimport Flag from 'cozy-ui/transpiled/react/Icons/Flag'\nimport FlagOutlined from 'cozy-ui/transpiled/react/Icons/FlagOutlined'\nimport FlashAuto from 'cozy-ui/transpiled/react/Icons/FlashAuto'\nimport Flashlight from 'cozy-ui/transpiled/react/Icons/Flashlight'\nimport Folder from 'cozy-ui/transpiled/react/Icons/Folder'\nimport FolderAdd from 'cozy-ui/transpiled/react/Icons/FolderAdd'\nimport FolderMoveto from 'cozy-ui/transpiled/react/Icons/FolderMoveto'\nimport FolderOpen from 'cozy-ui/transpiled/react/Icons/FolderOpen'\nimport Forbidden from 'cozy-ui/transpiled/react/Icons/Forbidden'\nimport FromUser from 'cozy-ui/transpiled/react/Icons/FromUser'\nimport Gear from 'cozy-ui/transpiled/react/Icons/Gear'\nimport Globe from 'cozy-ui/transpiled/react/Icons/Globe'\nimport Gouv from 'cozy-ui/transpiled/react/Icons/Gouv'\nimport GraphCircle from 'cozy-ui/transpiled/react/Icons/GraphCircle'\nimport GridIcon from 'cozy-ui/transpiled/react/Icons/Grid'\nimport GroupList from 'cozy-ui/transpiled/react/Icons/GroupList'\nimport Groups from 'cozy-ui/transpiled/react/Icons/Groups'\nimport Growth from 'cozy-ui/transpiled/react/Icons/Growth'\nimport Hand from 'cozy-ui/transpiled/react/Icons/Hand'\nimport Heart from 'cozy-ui/transpiled/react/Icons/Heart'\nimport Help from 'cozy-ui/transpiled/react/Icons/Help'\nimport HelpOutlined from 'cozy-ui/transpiled/react/Icons/HelpOutlined'\nimport History from 'cozy-ui/transpiled/react/Icons/History'\nimport Home from 'cozy-ui/transpiled/react/Icons/Home'\nimport Hourglass from 'cozy-ui/transpiled/react/Icons/Hourglass'\nimport Image from 'cozy-ui/transpiled/react/Icons/Image'\nimport Info from 'cozy-ui/transpiled/react/Icons/Info'\nimport InfoOutlined from 'cozy-ui/transpiled/react/Icons/InfoOutlined'\nimport Justice from 'cozy-ui/transpiled/react/Icons/Justice'\nimport Key from 'cozy-ui/transpiled/react/Icons/Key'\nimport Key2 from 'cozy-ui/transpiled/react/Icons/Key2'\nimport LabelOutlined from 'cozy-ui/transpiled/react/Icons/LabelOutlined'\nimport Laptop from 'cozy-ui/transpiled/react/Icons/Laptop'\nimport Laudry from 'cozy-ui/transpiled/react/Icons/Laudry'\nimport Left from 'cozy-ui/transpiled/react/Icons/Left'\nimport Library from 'cozy-ui/transpiled/react/Icons/Library'\nimport Lightbulb from 'cozy-ui/transpiled/react/Icons/Lightbulb'\nimport Lightning from 'cozy-ui/transpiled/react/Icons/Lightning'\nimport Link from 'cozy-ui/transpiled/react/Icons/Link'\nimport LinkOut from 'cozy-ui/transpiled/react/Icons/LinkOut'\nimport List from 'cozy-ui/transpiled/react/Icons/List'\nimport ListMin from 'cozy-ui/transpiled/react/Icons/ListMin'\nimport Location from 'cozy-ui/transpiled/react/Icons/Location'\nimport Lock from 'cozy-ui/transpiled/react/Icons/Lock'\nimport LockScreen from 'cozy-ui/transpiled/react/Icons/LockScreen'\nimport Logout from 'cozy-ui/transpiled/react/Icons/Logout'\nimport MagicTrick from 'cozy-ui/transpiled/react/Icons/MagicTrick'\nimport Magnet from 'cozy-ui/transpiled/react/Icons/Magnet'\nimport Magnifier from 'cozy-ui/transpiled/react/Icons/Magnifier'\nimport Matrix from 'cozy-ui/transpiled/react/Icons/Matrix'\nimport Merge from 'cozy-ui/transpiled/react/Icons/Merge'\nimport Moped from 'cozy-ui/transpiled/react/Icons/Moped'\nimport Mosaic from 'cozy-ui/transpiled/react/Icons/Mosaic'\nimport MosaicMin from 'cozy-ui/transpiled/react/Icons/MosaicMin'\nimport Motorcycle from 'cozy-ui/transpiled/react/Icons/Motorcycle'\nimport Mountain from 'cozy-ui/transpiled/react/Icons/Mountain'\nimport Movement from 'cozy-ui/transpiled/react/Icons/Movement'\nimport MovementIn from 'cozy-ui/transpiled/react/Icons/MovementIn'\nimport MovementOut from 'cozy-ui/transpiled/react/Icons/MovementOut'\nimport Moveto from 'cozy-ui/transpiled/react/Icons/Moveto'\nimport MultiFiles from 'cozy-ui/transpiled/react/Icons/MultiFiles'\nimport Music from 'cozy-ui/transpiled/react/Icons/Music'\nimport New from 'cozy-ui/transpiled/react/Icons/New'\nimport Next from 'cozy-ui/transpiled/react/Icons/Next'\nimport Note from 'cozy-ui/transpiled/react/Icons/Note'\nimport NotificationEmail from 'cozy-ui/transpiled/react/Icons/NotificationEmail'\nimport Number from 'cozy-ui/transpiled/react/Icons/Number'\nimport Offline from 'cozy-ui/transpiled/react/Icons/Offline'\nimport Online from 'cozy-ui/transpiled/react/Icons/Online'\nimport Openapp from 'cozy-ui/transpiled/react/Icons/Openapp'\nimport Openwith from 'cozy-ui/transpiled/react/Icons/Openwith'\nimport Palette from 'cozy-ui/transpiled/react/Icons/Palette'\nimport Paper from 'cozy-ui/transpiled/react/Icons/Paper'\nimport Paperplane from 'cozy-ui/transpiled/react/Icons/Paperplane'\nimport Password from 'cozy-ui/transpiled/react/Icons/Password'\nimport Pen from 'cozy-ui/transpiled/react/Icons/Pen'\nimport People from 'cozy-ui/transpiled/react/Icons/People'\nimport Peoples from 'cozy-ui/transpiled/react/Icons/Peoples'\nimport Percent from 'cozy-ui/transpiled/react/Icons/Percent'\nimport PercentCircle from 'cozy-ui/transpiled/react/Icons/PercentCircle'\nimport PersonAdd from 'cozy-ui/transpiled/react/Icons/PersonAdd'\nimport PersonalData from 'cozy-ui/transpiled/react/Icons/PersonalData'\nimport Phone from 'cozy-ui/transpiled/react/Icons/Phone'\nimport PhoneDownload from 'cozy-ui/transpiled/react/Icons/PhoneDownload'\nimport PhoneUpload from 'cozy-ui/transpiled/react/Icons/PhoneUpload'\nimport PieChart from 'cozy-ui/transpiled/react/Icons/PieChart'\nimport Pin from 'cozy-ui/transpiled/react/Icons/Pin'\nimport Plane from 'cozy-ui/transpiled/react/Icons/Plane'\nimport Plus from 'cozy-ui/transpiled/react/Icons/Plus'\nimport PlusSmall from 'cozy-ui/transpiled/react/Icons/PlusSmall'\nimport PopInside from 'cozy-ui/transpiled/react/Icons/PopInside'\nimport Previous from 'cozy-ui/transpiled/react/Icons/Previous'\nimport Printer from 'cozy-ui/transpiled/react/Icons/Printer'\nimport Qualify from 'cozy-ui/transpiled/react/Icons/Qualify'\nimport RadioChecked from 'cozy-ui/transpiled/react/Icons/RadioChecked'\nimport RadioUnchecked from 'cozy-ui/transpiled/react/Icons/RadioUnchecked'\nimport Refresh from 'cozy-ui/transpiled/react/Icons/Refresh'\nimport Relationship from 'cozy-ui/transpiled/react/Icons/Relationship'\nimport Remboursement from 'cozy-ui/transpiled/react/Icons/Remboursement'\nimport Rename from 'cozy-ui/transpiled/react/Icons/Rename'\nimport Repare from 'cozy-ui/transpiled/react/Icons/Repare'\nimport Reply from 'cozy-ui/transpiled/react/Icons/Reply'\nimport Restaurant from 'cozy-ui/transpiled/react/Icons/Restaurant'\nimport Restore from 'cozy-ui/transpiled/react/Icons/Restore'\nimport RestoreStraight from 'cozy-ui/transpiled/react/Icons/RestoreStraight'\nimport Right from 'cozy-ui/transpiled/react/Icons/Right'\nimport Rise from 'cozy-ui/transpiled/react/Icons/Rise'\nimport RotateLeft from 'cozy-ui/transpiled/react/Icons/RotateLeft'\nimport RotateRight from 'cozy-ui/transpiled/react/Icons/RotateRight'\nimport SadCozy from 'cozy-ui/transpiled/react/Icons/SadCozy'\nimport Safe from 'cozy-ui/transpiled/react/Icons/Safe'\nimport School from 'cozy-ui/transpiled/react/Icons/School'\nimport Scooter from 'cozy-ui/transpiled/react/Icons/Scooter'\nimport Security from 'cozy-ui/transpiled/react/Icons/Security'\nimport SelectAll from 'cozy-ui/transpiled/react/Icons/SelectAll'\nimport Server from 'cozy-ui/transpiled/react/Icons/Server'\nimport Setting from 'cozy-ui/transpiled/react/Icons/Setting'\nimport Share from 'cozy-ui/transpiled/react/Icons/Share'\nimport ShareCircle from 'cozy-ui/transpiled/react/Icons/ShareCircle'\nimport Shield from 'cozy-ui/transpiled/react/Icons/Shield'\nimport Shop from 'cozy-ui/transpiled/react/Icons/Shop'\nimport Sound from 'cozy-ui/transpiled/react/Icons/Sound'\nimport Spinner from 'cozy-ui/transpiled/react/Icons/Spinner'\nimport SportBag from 'cozy-ui/transpiled/react/Icons/SportBag'\nimport Stack from 'cozy-ui/transpiled/react/Icons/Stack'\nimport Star from 'cozy-ui/transpiled/react/Icons/Star'\nimport StarOutline from 'cozy-ui/transpiled/react/Icons/StarOutline'\nimport Stats from 'cozy-ui/transpiled/react/Icons/Stats'\nimport Stop from 'cozy-ui/transpiled/react/Icons/Stop'\nimport Subway from 'cozy-ui/transpiled/react/Icons/Subway'\nimport Support from 'cozy-ui/transpiled/react/Icons/Support'\nimport Swap from 'cozy-ui/transpiled/react/Icons/Swap'\nimport Sync from 'cozy-ui/transpiled/react/Icons/Sync'\nimport Tab from 'cozy-ui/transpiled/react/Icons/Tab'\nimport Tag from 'cozy-ui/transpiled/react/Icons/Tag'\nimport Target from 'cozy-ui/transpiled/react/Icons/Target'\nimport Task from 'cozy-ui/transpiled/react/Icons/Task'\nimport Team from 'cozy-ui/transpiled/react/Icons/Team'\nimport Telecom from 'cozy-ui/transpiled/react/Icons/Telecom'\nimport Telephone from 'cozy-ui/transpiled/react/Icons/Telephone'\nimport Text from 'cozy-ui/transpiled/react/Icons/Text'\nimport TextInfo from 'cozy-ui/transpiled/react/Icons/TextInfo'\nimport ToTheCloud from 'cozy-ui/transpiled/react/Icons/ToTheCloud'\nimport Top from 'cozy-ui/transpiled/react/Icons/Top'\nimport Train from 'cozy-ui/transpiled/react/Icons/Train'\nimport Tram from 'cozy-ui/transpiled/react/Icons/Tram'\nimport Trash from 'cozy-ui/transpiled/react/Icons/Trash'\nimport Trophy from 'cozy-ui/transpiled/react/Icons/Trophy'\nimport Uncloud from 'cozy-ui/transpiled/react/Icons/Uncloud'\nimport Unknow from 'cozy-ui/transpiled/react/Icons/Unknow'\nimport Unlink from 'cozy-ui/transpiled/react/Icons/Unlink'\nimport Unlock from 'cozy-ui/transpiled/react/Icons/Unlock'\nimport Up from 'cozy-ui/transpiled/react/Icons/Up'\nimport Upload from 'cozy-ui/transpiled/react/Icons/Upload'\nimport Videos from 'cozy-ui/transpiled/react/Icons/Videos'\nimport Walk from 'cozy-ui/transpiled/react/Icons/Walk'\nimport Wallet from 'cozy-ui/transpiled/react/Icons/Wallet'\nimport WalletAdd from 'cozy-ui/transpiled/react/Icons/WalletAdd'\nimport WalletNew from 'cozy-ui/transpiled/react/Icons/WalletNew'\nimport Warn from 'cozy-ui/transpiled/react/Icons/Warn'\nimport Warning from 'cozy-ui/transpiled/react/Icons/Warning'\nimport WarningCircle from 'cozy-ui/transpiled/react/Icons/WarningCircle'\nimport Water from 'cozy-ui/transpiled/react/Icons/Water'\nimport Work from 'cozy-ui/transpiled/react/Icons/Work'\nimport WrenchCircle from 'cozy-ui/transpiled/react/Icons/WrenchCircle'\n\nconst icons = {\n  AlbumAdd,\n  AlbumRemove,\n  Album,\n  Answer,\n  Apple,\n  Archive,\n  ArrowUp,\n  AssignAdmin,\n  AssignModerator,\n  Assistant,\n  Attachment,\n  Attention,\n  Bank,\n  BankCheck,\n  Banking,\n  BankingAdd,\n  Bell,\n  Benefit,\n  Bike,\n  Bill,\n  Bottom,\n  BrowserBrave,\n  BrowserChrome,\n  BrowserDuckduckgo,\n  BrowserEdge,\n  BrowserEdgeChromium,\n  BrowserFirefox,\n  BrowserIe,\n  BrowserOpera,\n  BrowserSafari,\n  Burger,\n  Bus,\n  Calendar,\n  Camera,\n  Car,\n  CarbonCopy,\n  CarPooling,\n  Categories,\n  Certified,\n  Check,\n  Checkbox,\n  CheckCircle,\n  CheckList,\n  CheckSquare,\n  Chess,\n  Child,\n  CircleFilled,\n  Clock,\n  ClockOutline,\n  Cloud,\n  Cloud2,\n  CloudHappy,\n  CloudPlusOutlined,\n  Cocktail,\n  Collect,\n  Comment,\n  Company,\n  Compare,\n  Compass,\n  Connector,\n  Contract,\n  Contrast,\n  Copy,\n  CozyCircle,\n  CozyLaugh,\n  CozyLock,\n  CozyText,\n  Credit,\n  CreditCard,\n  CreditCardAdd,\n  Crop,\n  CrossCircleOutline,\n  CrossCircle,\n  CrossMedium,\n  CrossSmall,\n  Cross,\n  Cube,\n  Dash,\n  Dashboard,\n  DataControl,\n  Debit,\n  DesktopDownload,\n  Devices,\n  Discuss,\n  Dots,\n  Down,\n  Download,\n  DrawingArrowUp,\n  DropdownClose,\n  DropdownOpen,\n  Dropdown,\n  Dropup,\n  ElectricBike,\n  ElectricCar,\n  ElectricScooter,\n  EmailNotification,\n  EmailOpen,\n  Email,\n  Eu,\n  Euro,\n  Exchange,\n  Eye,\n  EyeClosed,\n  FaceId,\n  FileAdd,\n  FileDuotone,\n  FileNew,\n  FileNone,\n  FileOutline,\n  File,\n  Filter,\n  Fingerprint,\n  Fitness,\n  Flag,\n  FlagOutlined,\n  FlashAuto,\n  Flashlight,\n  Folder,\n  FolderAdd,\n  FolderMoveto,\n  FolderOpen,\n  Forbidden,\n  FromUser,\n  Gear,\n  Globe,\n  Gouv,\n  GraphCircle,\n  GridIcon,\n  GroupList,\n  Groups,\n  Growth,\n  Hand,\n  Heart,\n  Help,\n  HelpOutlined,\n  History,\n  Home,\n  Hourglass,\n  Image,\n  InfoOutlined,\n  Info,\n  Justice,\n  Key,\n  Key2,\n  LabelOutlined,\n  Laudry,\n  Laptop,\n  Left,\n  Library,\n  Lightbulb,\n  Lightning,\n  Link,\n  LinkOut,\n  List,\n  ListMin,\n  Location,\n  Lock,\n  LockScreen,\n  Logout,\n  MagicTrick,\n  Magnet,\n  Magnifier,\n  Matrix,\n  Merge,\n  Moped,\n  Mosaic,\n  MosaicMin,\n  Motorcycle,\n  Mountain,\n  MovementIn,\n  MovementOut,\n  Movement,\n  Moveto,\n  MultiFiles,\n  Music,\n  New,\n  Next,\n  Note,\n  NotificationEmail,\n  Number,\n  Offline,\n  Online,\n  Openapp,\n  Openwith,\n  Palette,\n  Paper,\n  Paperplane,\n  Password,\n  Pen,\n  People,\n  Peoples,\n  Percent,\n  PercentCircle,\n  PersonAdd,\n  PersonalData,\n  PhoneDownload,\n  PhoneUpload,\n  Phone,\n  PieChart,\n  Pin,\n  Plane,\n  PlusSmall,\n  Plus,\n  PopInside,\n  Previous,\n  Printer,\n  Qualify,\n  RadioChecked,\n  RadioUnchecked,\n  Refresh,\n  Relationship,\n  Remboursement,\n  Rename,\n  Repare,\n  Reply,\n  Restaurant,\n  Restore,\n  RestoreStraight,\n  Right,\n  Rise,\n  RotateLeft,\n  RotateRight,\n  SadCozy,\n  Safe,\n  School,\n  Scooter,\n  Security,\n  SelectAll,\n  Server,\n  Setting,\n  Share,\n  ShareCircle,\n  Shield,\n  Shop,\n  Sound,\n  Spinner,\n  SportBag,\n  Stack,\n  Star,\n  StarOutline,\n  Stats,\n  Stop,\n  Subway,\n  Support,\n  Swap,\n  Sync,\n  Tab,\n  Tag,\n  Target,\n  Task,\n  Team,\n  Telecom,\n  Telephone,\n  Text,\n  TextInfo,\n  Top,\n  ToTheCloud,\n  Train,\n  Tram,\n  Trash,\n  Trophy,\n  Uncloud,\n  Unknow,\n  Unlink,\n  Unlock,\n  Up,\n  Upload,\n  Videos,\n  Walk,\n  WalletAdd,\n  WalletNew,\n  Wallet,\n  Warn,\n  WarningCircle,\n  Warning,\n  Water,\n  WrenchCircle,\n  Work\n}\n\nexport function getIcon(iconName) {\n  return icons[iconName]\n}\n\nexport function getIconList() {\n  return Object.keys(icons)\n}\n"
  },
  {
    "path": "src/components/IconPicker/IconPicker.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React, { useState, useEffect } from 'react'\n\nimport Backdrop from 'cozy-ui/transpiled/react/Backdrop'\nimport GridList from 'cozy-ui/transpiled/react/GridList'\nimport GridListTile from 'cozy-ui/transpiled/react/GridListTile'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport { Spinner } from 'cozy-ui/transpiled/react/Spinner'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { IconColorPicker } from './IconColorPicker'\nimport { getIcon, getIconList } from './IconIndex'\nimport { NoneIcon } from './NoneIcon'\nimport {\n  NB_COLUMNS_MOBILE,\n  NB_COLUMNS_DESKTOP,\n  CELL_HEIGHT_MOBILE,\n  CELL_HEIGHT_DESKTOP,\n  ICON_SIZE_MOBILE,\n  ICON_SIZE_DESKTOP\n} from './constants'\n\nimport { useRecentIcons } from '@/hooks'\n\n/**\n * IconPicker component - displays a grid of icons and allows the user to select one\n * @param {Object} props\n * @param {string} props.selectedIcon - Currently selected icon\n * @param {Function} props.onIconSelect - Callback function when an icon is selected\n */\n\nexport const IconPicker = ({\n  selectedIcon,\n  onIconSelect,\n  onIconColorSelect,\n  scrollContainerRef\n}) => {\n  const { isMobile } = useBreakpoints()\n  const { t } = useI18n()\n  const recentIcons = useRecentIcons()\n  const icons = getIconList()\n  const [anchorEl, setAnchorEl] = useState(null)\n\n  const iconSize = isMobile ? ICON_SIZE_MOBILE : ICON_SIZE_DESKTOP\n\n  // Close color picker when scrolling\n  useEffect(() => {\n    const container = scrollContainerRef?.current\n    if (!anchorEl || !container) return\n\n    const handleScroll = () => {\n      setAnchorEl(null)\n    }\n\n    container.addEventListener('scroll', handleScroll, true)\n\n    return () => {\n      container.removeEventListener('scroll', handleScroll, true)\n    }\n  }, [anchorEl, scrollContainerRef])\n\n  const handleIconClick = (event, iconName) => {\n    if (onIconColorSelect && iconName !== 'none') {\n      setAnchorEl(prev => (prev ? null : event.currentTarget))\n    }\n    onIconSelect(iconName)\n  }\n\n  const handleColorPick = color => {\n    onIconColorSelect?.(color)\n    setAnchorEl(null)\n  }\n\n  if (!recentIcons) {\n    return (\n      <Backdrop isOver open>\n        <Spinner size=\"xxlarge\" middle noMargin color=\"var(--white)\" />\n      </Backdrop>\n    )\n  }\n\n  return (\n    <>\n      {recentIcons.length > 0 && (\n        <>\n          <Typography className=\"u-ml-half u-mb-1\" variant=\"h6\" noWrap>\n            {t('FolderCustomizer.iconPicker.recents')}\n          </Typography>\n          <GridList\n            cols={isMobile ? NB_COLUMNS_MOBILE : NB_COLUMNS_DESKTOP}\n            cellHeight={isMobile ? CELL_HEIGHT_MOBILE : CELL_HEIGHT_DESKTOP}\n          >\n            {recentIcons.map((iconName, index) => (\n              <GridListTile key={`recent-${index}`} className=\"u-ta-center\">\n                <IconButton\n                  onClick={() => onIconSelect(iconName)}\n                  size={isMobile ? ICON_SIZE_MOBILE : ICON_SIZE_DESKTOP}\n                >\n                  <Icon\n                    size={isMobile ? ICON_SIZE_MOBILE : ICON_SIZE_DESKTOP}\n                    icon={getIcon(iconName)}\n                  />\n                </IconButton>\n              </GridListTile>\n            ))}\n          </GridList>\n        </>\n      )}\n      <Typography\n        className=\"u-ml-half u-mb-1 u-mt-1 u-mt-0-t\"\n        variant=\"h6\"\n        noWrap\n      >\n        {t('FolderCustomizer.iconPicker.chooseCustomIcon')}\n      </Typography>\n      <GridList\n        cols={isMobile ? NB_COLUMNS_MOBILE : NB_COLUMNS_DESKTOP}\n        cellHeight={isMobile ? CELL_HEIGHT_MOBILE : CELL_HEIGHT_DESKTOP}\n      >\n        <GridListTile key=\"none\" className=\"u-ta-center\">\n          <IconButton onClick={e => handleIconClick(e, 'none')} size={iconSize}>\n            <NoneIcon size={iconSize} />\n          </IconButton>\n        </GridListTile>\n\n        {icons.map((iconName, index) => (\n          <GridListTile key={index} className=\"u-ta-center\">\n            <IconButton\n              onClick={e => handleIconClick(e, iconName)}\n              size={iconSize}\n              className={selectedIcon === iconName ? 'u-bg-silver' : ''}\n            >\n              <Icon size={iconSize} icon={getIcon(iconName)} />\n            </IconButton>\n            {selectedIcon === iconName && Boolean(anchorEl) && (\n              <IconColorPicker\n                anchorEl={anchorEl}\n                selectedIcon={selectedIcon}\n                iconSize={iconSize}\n                isMobile={isMobile}\n                onPickColor={handleColorPick}\n                onClose={() => setAnchorEl(null)}\n              />\n            )}\n          </GridListTile>\n        ))}\n      </GridList>\n    </>\n  )\n}\n\nIconPicker.propTypes = {\n  selectedIcon: PropTypes.string,\n  onIconSelect: PropTypes.func.isRequired,\n  onIconColorSelect: PropTypes.func,\n  scrollContainerRef: PropTypes.shape({ current: PropTypes.any })\n}\n\nIconPicker.displayName = 'IconPicker'\n"
  },
  {
    "path": "src/components/IconPicker/NoneIcon.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport Cross from 'cozy-ui/transpiled/react/Icons/Cross'\n\nimport styles from '@/styles/folder-customizer.styl'\n\n/**\n * NoneIcon component - displays a grey square with an X to represent \"no icon\"\n * @param {Object} props\n * @param {number} props.size - Size of the icon\n */\nexport const NoneIcon = ({ size }) => {\n  const iconSize = Math.round(size * 0.65)\n\n  return (\n    <span\n      className={`${styles.noneIconFrame} u-flex u-flex-items-center u-flex-justify-center`}\n      style={{\n        width: size,\n        height: size\n      }}\n      aria-hidden\n    >\n      <Cross\n        width={iconSize}\n        height={iconSize}\n        fill=\"var(--secondaryTextColor)\"\n      />\n    </span>\n  )\n}\n\nNoneIcon.propTypes = {\n  size: PropTypes.number\n}\n\nNoneIcon.displayName = 'NoneIcon'\n"
  },
  {
    "path": "src/components/IconPicker/constants.js",
    "content": "export const ICON_COLORS = [\n  '#2c2c2c',\n  '#9aa0a6',\n  '#e0e0e0',\n  '#8d5e3c',\n  '#ffb300',\n  '#66e49a',\n  '#00a6ff',\n  '#1976d2',\n  '#e53935',\n  '#f48fb1',\n  '#ab47bc',\n  '#6a1b9a'\n]\n\nexport const NB_COLUMNS_MOBILE = 6\nexport const NB_COLUMNS_DESKTOP = 8\nexport const CELL_HEIGHT_MOBILE = 56\nexport const CELL_HEIGHT_DESKTOP = 42\nexport const ICON_SIZE_MOBILE = 20\nexport const ICON_SIZE_DESKTOP = 18\n"
  },
  {
    "path": "src/components/IconPicker/index.jsx",
    "content": "export { IconPicker } from './IconPicker'\n"
  },
  {
    "path": "src/components/IconStack/index.jsx",
    "content": "import classNames from 'classnames'\nimport PropTypes from 'prop-types'\nimport React from 'react'\n\nimport styles from './styles.styl'\n\nconst IconStack = ({\n  backgroundClassName,\n  foregroundClassName,\n  backgroundIcon,\n  foregroundIcon,\n  offset\n}) => {\n  return (\n    <div\n      className={classNames(styles['IconStack-wrapper'], backgroundClassName)}\n    >\n      {backgroundIcon}\n      {foregroundIcon && (\n        <div\n          style={{\n            marginTop: offset?.vertical,\n            marginLeft: offset?.horizontal\n          }}\n          className={classNames(\n            styles['IconStack-foregroundIcon'],\n            foregroundClassName\n          )}\n        >\n          {foregroundIcon}\n        </div>\n      )}\n    </div>\n  )\n}\n\nIconStack.propTypes = {\n  backgroundClassName: PropTypes.string,\n  foregroundClassName: PropTypes.string,\n  backgroundIcon: PropTypes.node,\n  foregroundIcon: PropTypes.node,\n  offset: PropTypes.shape({\n    vertical: PropTypes.string,\n    horizontal: PropTypes.string\n  })\n}\n\nexport default IconStack\n"
  },
  {
    "path": "src/components/IconStack/styles.styl",
    "content": ".IconStack-wrapper\n    position relative\n    display inline-block\n\n.IconStack-foregroundIcon\n    position absolute\n    left 50%\n    top 50%\n    transform translate(-50%, -50%)\n"
  },
  {
    "path": "src/components/Icons/Drive.jsx",
    "content": "// Automatically created, please run `scripts/generate-svgr-icon.sh assets/icons/illus/drive.svg` to regenerate;\nimport React from 'react'\n\nfunction SvgDrive(props) {\n  return (\n    <svg viewBox=\"0 0 33 33\" fill=\"none\" {...props}>\n      <rect\n        x={0.618}\n        y={0.718}\n        width={32}\n        height={32}\n        rx={10.568}\n        fill=\"url(#drive_svg__paint0_linear_11347_30928)\"\n      />\n      <g filter=\"url(#drive_svg__filter0_d_11347_30928)\" fill=\"#fff\">\n        <path\n          fillRule=\"evenodd\"\n          clipRule=\"evenodd\"\n          d=\"M16.61 18.913a5.25 5.25 0 00-.228-1.54 5.455 5.455 0 00-.398-.96 5.25 5.25 0 00-.927-1.25 5.318 5.318 0 00-2.972-1.496 5.484 5.484 0 00-.779-.058 5.25 5.25 0 00-1.54.229 5.358 5.358 0 00-1.406.665 5.288 5.288 0 00-1.953 2.38 5.276 5.276 0 00-.398 2.29 5.306 5.306 0 005.037 5.037c.087.004.174.006.26.006h8.486v-5.303H16.61z\"\n        />\n        <path d=\"M19.791 24.216a7.425 7.425 0 100-14.85 7.425 7.425 0 000 14.85z\" />\n      </g>\n      <defs>\n        <linearGradient\n          id=\"drive_svg__paint0_linear_11347_30928\"\n          x1={4.126}\n          y1={29.682}\n          x2={39.046}\n          y2={-5.32}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop offset={0.248} stopColor=\"#FF4759\" />\n          <stop offset={1} stopColor=\"#FFD600\" />\n        </linearGradient>\n        <filter\n          id=\"drive_svg__filter0_d_11347_30928\"\n          x={4.371}\n          y={8.061}\n          width={24.477}\n          height={18.113}\n          filterUnits=\"userSpaceOnUse\"\n          colorInterpolationFilters=\"sRGB\"\n        >\n          <feFlood floodOpacity={0} result=\"BackgroundImageFix\" />\n          <feColorMatrix\n            in=\"SourceAlpha\"\n            values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0\"\n            result=\"hardAlpha\"\n          />\n          <feOffset dy={0.326} />\n          <feGaussianBlur stdDeviation={0.816} />\n          <feComposite in2=\"hardAlpha\" operator=\"out\" />\n          <feColorMatrix values=\"0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0\" />\n          <feBlend\n            in2=\"BackgroundImageFix\"\n            result=\"effect1_dropShadow_11347_30928\"\n          />\n          <feBlend\n            in=\"SourceGraphic\"\n            in2=\"effect1_dropShadow_11347_30928\"\n            result=\"shape\"\n          />\n        </filter>\n      </defs>\n    </svg>\n  )\n}\n\nexport default SvgDrive\n"
  },
  {
    "path": "src/components/Icons/DriveText.jsx",
    "content": "// Automatically created, please run `scripts/generate-svgr-icon.sh assets/icons/illus/drive-text.svg` to regenerate;\nimport React from 'react'\n\nfunction SvgDriveText(props) {\n  return (\n    <svg viewBox=\"0 0 374 123\" fill=\"none\" {...props}>\n      <path\n        d=\"M0 3.66h41.593c13.088 0 24.068 2.496 32.941 7.487 8.984 4.991 15.695 11.868 20.131 20.63 4.437 8.762 6.655 18.855 6.655 30.28 0 11.313-2.218 21.35-6.655 30.113-4.436 8.762-11.147 15.639-20.13 20.63-8.874 4.88-19.854 7.32-32.942 7.32H0V3.66zm38.764 95.497c13.088 0 22.738-3.327 28.95-9.982 6.321-6.766 9.482-15.805 9.482-27.118 0-11.314-3.16-20.353-9.483-27.119-6.211-6.877-15.86-10.315-28.948-10.315H23.291v74.534h15.472z\"\n        fill=\"url(#drive-text_svg__paint0_linear_59620_158)\"\n      />\n      <path\n        d=\"M112.067 33.607h21.794v16.47c1.553-6.321 4.548-11.09 8.984-14.307 4.548-3.217 10.482-4.603 17.802-4.16v21.13h-3.161c-6.655 0-12.145 2.107-16.471 6.322-4.325 4.104-6.488 9.705-6.488 16.803v44.255h-22.46V33.607z\"\n        fill=\"url(#drive-text_svg__paint1_linear_59620_158)\"\n      />\n      <path\n        d=\"M168.119 33.607h22.46v86.513h-22.46V33.607zm-2.329-20.464c0-3.66 1.276-6.765 3.827-9.316C172.279 1.276 175.495 0 179.266 0c3.882 0 7.099 1.276 9.65 3.827 2.551 2.55 3.826 5.656 3.826 9.316 0 3.771-1.275 6.933-3.826 9.483-2.551 2.551-5.768 3.827-9.65 3.827-3.771 0-6.987-1.276-9.649-3.826-2.551-2.662-3.827-5.823-3.827-9.484z\"\n        fill=\"url(#drive-text_svg__paint2_linear_59620_158)\"\n      />\n      <path\n        d=\"M194.713 33.607h26.619l17.303 49.412c1.553 4.215 2.773 8.818 3.66 13.809.887-4.991 2.107-9.594 3.66-13.809l17.303-49.412h26.12l-35.936 86.513h-22.627l-36.102-86.513z\"\n        fill=\"url(#drive-text_svg__paint3_linear_59620_158)\"\n      />\n      <path\n        d=\"M329.45 122.283c-8.762 0-16.526-1.997-23.292-5.99-6.766-4.103-12.034-9.594-15.805-16.47-3.771-6.877-5.657-14.475-5.657-22.793 0-8.208 1.941-15.805 5.823-22.793 3.882-6.988 9.206-12.533 15.972-16.637 6.877-4.104 14.53-6.156 22.959-6.156 8.43 0 15.972 2.052 22.627 6.156 6.765 4.104 11.978 9.65 15.638 16.637 3.772 6.877 5.657 14.474 5.657 22.793 0 2.773-.222 5.435-.665 7.986h-64.719c1.331 5.213 3.827 9.427 7.487 12.644 3.771 3.105 8.429 4.658 13.975 4.658 4.658 0 8.818-1.053 12.478-3.16 3.66-2.219 6.544-5.047 8.651-8.486l17.469 13.144c-3.549 5.545-8.762 10.037-15.639 13.476-6.876 3.327-14.529 4.991-22.959 4.991zm21.296-54.07c-1.331-4.992-3.938-9.151-7.82-12.479-3.882-3.327-8.485-4.99-13.809-4.99-5.213 0-9.76 1.608-13.642 4.824-3.771 3.217-6.267 7.431-7.487 12.644h42.758z\"\n        fill=\"url(#drive-text_svg__paint4_linear_59620_158)\"\n      />\n      <defs>\n        <linearGradient\n          id=\"drive-text_svg__paint0_linear_59620_158\"\n          x1={172.182}\n          y1={-31}\n          x2={172.182}\n          y2={137.5}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#FF6372\" />\n          <stop offset={1} stopColor=\"#FF3347\" />\n        </linearGradient>\n        <linearGradient\n          id=\"drive-text_svg__paint1_linear_59620_158\"\n          x1={172.182}\n          y1={-31}\n          x2={172.182}\n          y2={137.5}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#FF6372\" />\n          <stop offset={1} stopColor=\"#FF3347\" />\n        </linearGradient>\n        <linearGradient\n          id=\"drive-text_svg__paint2_linear_59620_158\"\n          x1={172.182}\n          y1={-31}\n          x2={172.182}\n          y2={137.5}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#FF6372\" />\n          <stop offset={1} stopColor=\"#FF3347\" />\n        </linearGradient>\n        <linearGradient\n          id=\"drive-text_svg__paint3_linear_59620_158\"\n          x1={172.182}\n          y1={-31}\n          x2={172.182}\n          y2={137.5}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#FF6372\" />\n          <stop offset={1} stopColor=\"#FF3347\" />\n        </linearGradient>\n        <linearGradient\n          id=\"drive-text_svg__paint4_linear_59620_158\"\n          x1={172.182}\n          y1={-31}\n          x2={172.182}\n          y2={137.5}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop stopColor=\"#FF6372\" />\n          <stop offset={1} stopColor=\"#FF3347\" />\n        </linearGradient>\n      </defs>\n    </svg>\n  )\n}\n\nexport default SvgDriveText\n"
  },
  {
    "path": "src/components/LoaderModal.jsx",
    "content": "import React from 'react'\n\nimport { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\n\n/**\n * Loading state for move alert modal\n */\nconst LoaderModal = () => {\n  return (\n    <ConfirmDialog\n      open\n      content={\n        <div className=\"u-h-3\">\n          <Spinner size=\"xlarge\" noMargin middle />\n        </div>\n      }\n    />\n  )\n}\n\nexport { LoaderModal }\n"
  },
  {
    "path": "src/components/Migration/MigrationProgressBanner.jsx",
    "content": "import React, { useCallback, useEffect, useState } from 'react'\n\nimport { useClient, useQuery } from 'cozy-client'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport Upload from 'cozy-ui/transpiled/react/Icons/Upload'\nimport ProgressionBanner from 'cozy-ui/transpiled/react/ProgressionBanner'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { NEXTCLOUD_MIGRATIONS_DOCTYPE } from '@/lib/doctypes'\nimport logger from '@/lib/logger'\nimport { buildRunningMigrationQuery } from '@/queries'\n\nconst SNACKBAR_AUTO_HIDE_MS = 6000\n\nconst computeMigrationPercent = progress => {\n  const imported = Number(progress?.bytes_imported) || 0\n  const total = Number(progress?.bytes_total) || 0\n\n  if (total <= 0) return 0\n\n  const percent = Math.round((imported / total) * 100)\n  return Number.isFinite(percent) ? Math.min(100, Math.max(0, percent)) : 0\n}\n\nconst showCompletedMigrationAlert = ({ doc, showAlert, t }) => {\n  if (doc.status !== 'completed') return\n\n  showAlert({\n    title: t('MigrationProgressBanner.done.title'),\n    message: t('MigrationProgressBanner.done.body', {\n      count: doc.progress?.files_total ?? 0\n    }),\n    severity: 'success',\n    duration: SNACKBAR_AUTO_HIDE_MS\n  })\n}\n\nconst useMigrationCompletionAlert = ({ migrationId }) => {\n  const client = useClient()\n  const { showAlert } = useAlert()\n  const { t } = useI18n()\n\n  useEffect(() => {\n    if (!migrationId) return\n\n    const handleMigrationUpdate = doc => {\n      showCompletedMigrationAlert({ doc, showAlert, t })\n    }\n\n    client.plugins.realtime.subscribe(\n      'updated',\n      NEXTCLOUD_MIGRATIONS_DOCTYPE,\n      migrationId,\n      handleMigrationUpdate\n    )\n\n    return () => {\n      client.plugins.realtime.unsubscribe(\n        'updated',\n        NEXTCLOUD_MIGRATIONS_DOCTYPE,\n        migrationId,\n        handleMigrationUpdate\n      )\n    }\n  }, [client, migrationId, showAlert, t])\n}\n\nconst useMigrationCancel = ({ migrationId }) => {\n  const client = useClient()\n  const [isCanceling, setIsCanceling] = useState(false)\n\n  const handleCancel = useCallback(async () => {\n    if (!migrationId || isCanceling) return\n\n    setIsCanceling(true)\n\n    try {\n      await client\n        .getStackClient()\n        .fetchJSON('POST', `/remote/nextcloud/migration/${migrationId}/cancel`)\n    } catch (e) {\n      if (e.status !== 409) logger.error('Migration cancel failed', e)\n    } finally {\n      setIsCanceling(false)\n    }\n  }, [client, isCanceling, migrationId])\n\n  return { isCanceling, handleCancel }\n}\n\nconst DumbMigrationProgressBanner = ({ migrationDoc }) => {\n  const { t } = useI18n()\n\n  const migrationId = migrationDoc?._id\n  const progress = migrationDoc?.progress\n  const percent = computeMigrationPercent(progress)\n\n  useMigrationCompletionAlert({ migrationId })\n\n  const { isCanceling, handleCancel } = useMigrationCancel({\n    migrationId\n  })\n\n  return (\n    <ProgressionBanner\n      icon={<Icon icon={Upload} size={16} />}\n      text={\n        <>\n          {t('MigrationProgressBanner.title')}\n          {' · '}\n          <span data-testid=\"migration-progress-banner-percent\">\n            {t('MigrationProgressBanner.percent', { percent })}\n          </span>\n        </>\n      }\n      value={percent}\n      button={\n        <>\n          <Typography\n            variant=\"body2\"\n            color=\"textSecondary\"\n            data-testid=\"migration-progress-banner-importing\"\n          >\n            {t('MigrationProgressBanner.importing', {\n              count: progress?.files_total ?? 0\n            })}\n          </Typography>\n          <Buttons\n            variant=\"text\"\n            size=\"small\"\n            label={t('MigrationProgressBanner.cancel')}\n            onClick={handleCancel}\n            disabled={isCanceling}\n            busy={isCanceling}\n            data-testid=\"migration-progress-banner-cancel\"\n          />\n        </>\n      }\n    />\n  )\n}\n\nexport const MigrationProgressBanner = () => {\n  const runningMigrationQuery = buildRunningMigrationQuery()\n  const { data: runningMigrations } = useQuery(\n    runningMigrationQuery.definition,\n    runningMigrationQuery.options\n  )\n  const migrationDoc = runningMigrations?.[0] ?? null\n\n  if (!migrationDoc) return null\n\n  return <DumbMigrationProgressBanner migrationDoc={migrationDoc} />\n}\n"
  },
  {
    "path": "src/components/Migration/MigrationProgressBanner.spec.jsx",
    "content": "import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient, useQuery } from 'cozy-client'\n\nimport { MigrationProgressBanner } from './MigrationProgressBanner'\nimport AppLike from 'test/components/AppLike'\n\nimport logger from '@/lib/logger'\n\njest.mock('cozy-client', () => {\n  const actual = jest.requireActual('cozy-client')\n  return {\n    __esModule: true,\n    ...actual,\n    default: actual.default,\n    useQuery: jest.fn()\n  }\n})\n\njest.mock('@/lib/logger', () => ({ error: jest.fn() }))\n\nconst RUNNING_DOC = {\n  _id: 'migration-1',\n  status: 'running',\n  progress: {\n    files_imported: 10,\n    files_total: 100,\n    bytes_imported: 1_000_000,\n    bytes_total: 5_000_000\n  }\n}\n\nconst buildMockClient = ({ fetchJSON } = {}) => {\n  const client = createMockClient({})\n  const subscribers = {}\n  const keyOf = (event, doctype, idOrHandler) =>\n    typeof idOrHandler === 'string'\n      ? `${event}:${doctype}:${idOrHandler}`\n      : `${event}:${doctype}`\n  client.plugins = {\n    realtime: {\n      subscribe: jest.fn((event, doctype, idOrHandler, handler) => {\n        const key = keyOf(event, doctype, idOrHandler)\n        subscribers[key] = handler || idOrHandler\n      }),\n      unsubscribe: jest.fn((event, doctype, idOrHandler) => {\n        delete subscribers[keyOf(event, doctype, idOrHandler)]\n      })\n    }\n  }\n  client.__emit = (event, doctype, doc) => {\n    const handler =\n      subscribers[`${event}:${doctype}:${doc._id}`] ||\n      subscribers[`${event}:${doctype}`]\n    if (handler) handler(doc)\n  }\n  client.getStackClient = () => ({\n    fetchJSON: fetchJSON || jest.fn().mockResolvedValue({})\n  })\n  return client\n}\n\nconst setup = ({ runningMigration = null, fetchJSON } = {}) => {\n  const client = buildMockClient({ fetchJSON })\n  useQuery.mockReturnValue({\n    data: runningMigration ? [runningMigration] : [],\n    fetchStatus: 'loaded'\n  })\n  const utils = render(\n    <AppLike client={client}>\n      <MigrationProgressBanner />\n    </AppLike>\n  )\n  const rerenderWith = nextRunning => {\n    useQuery.mockReturnValue({\n      data: nextRunning ? [nextRunning] : [],\n      fetchStatus: 'loaded'\n    })\n    utils.rerender(\n      <AppLike client={client}>\n        <MigrationProgressBanner />\n      </AppLike>\n    )\n  }\n  return { client, rerenderWith, ...utils }\n}\n\ndescribe('MigrationProgressBanner', () => {\n  beforeEach(() => jest.clearAllMocks())\n\n  describe('idle state', () => {\n    it('does not render the banner when no migration is running', () => {\n      setup()\n      expect(\n        screen.queryByTestId('migration-progress-banner-percent')\n      ).not.toBeInTheDocument()\n    })\n  })\n\n  describe('running state', () => {\n    it('renders the banner when a migration is running', () => {\n      // The ProgressionBanner of the cozy-ui version currently bundled in cozy-drive\n      // declares `text: PropTypes.string` while we pass a fragment to keep the\n      // `data-testid` wrapping. The next cozy-ui upgrade widens this to PropTypes.node.\n      const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation()\n      try {\n        setup({ runningMigration: RUNNING_DOC })\n        expect(\n          screen.getByTestId('migration-progress-banner-percent')\n        ).toBeInTheDocument()\n        expect(\n          screen.getByTestId('migration-progress-banner-percent')\n        ).toHaveTextContent('20% complete')\n        expect(\n          screen.getByTestId('migration-progress-banner-importing')\n        ).toHaveTextContent('100 files')\n      } finally {\n        consoleErrorSpy.mockRestore()\n      }\n    })\n\n    it('shows the banner when the query starts returning a running migration', () => {\n      const { rerenderWith } = setup()\n      expect(\n        screen.queryByTestId('migration-progress-banner-percent')\n      ).not.toBeInTheDocument()\n      rerenderWith(RUNNING_DOC)\n      expect(\n        screen.getByTestId('migration-progress-banner-percent')\n      ).toBeInTheDocument()\n    })\n\n    it('updates progress when the query data changes', () => {\n      const { rerenderWith } = setup({ runningMigration: RUNNING_DOC })\n      rerenderWith({\n        ...RUNNING_DOC,\n        progress: { ...RUNNING_DOC.progress, bytes_imported: 2_500_000 }\n      })\n      expect(\n        screen.getByTestId('migration-progress-banner-percent')\n      ).toHaveTextContent('50% complete')\n    })\n  })\n\n  describe('terminal states', () => {\n    it('hides the banner and shows the snackbar on completed', () => {\n      const { client, rerenderWith } = setup({ runningMigration: RUNNING_DOC })\n      act(() =>\n        client.__emit('updated', 'io.cozy.nextcloud.migrations', {\n          ...RUNNING_DOC,\n          status: 'completed'\n        })\n      )\n      rerenderWith(null)\n      expect(\n        screen.queryByTestId('migration-progress-banner-percent')\n      ).not.toBeInTheDocument()\n      expect(screen.getByText('Migration Complete!')).toBeInTheDocument()\n    })\n\n    it('hides the banner without snackbar when the running query empties', () => {\n      const { rerenderWith } = setup({ runningMigration: RUNNING_DOC })\n      rerenderWith(null)\n      expect(\n        screen.queryByTestId('migration-progress-banner-percent')\n      ).not.toBeInTheDocument()\n      expect(screen.queryByText('Migration Complete!')).not.toBeInTheDocument()\n    })\n  })\n\n  describe('cancel', () => {\n    it('calls the cancel endpoint when the cancel button is clicked', async () => {\n      const fetchJSON = jest.fn().mockResolvedValue({})\n      setup({ runningMigration: RUNNING_DOC, fetchJSON })\n      fireEvent.click(screen.getByTestId('migration-progress-banner-cancel'))\n      await waitFor(() =>\n        expect(fetchJSON).toHaveBeenCalledWith(\n          'POST',\n          '/remote/nextcloud/migration/migration-1/cancel'\n        )\n      )\n    })\n\n    it('silently ignores a 409 response', async () => {\n      const fetchJSON = jest.fn().mockRejectedValue({ status: 409 })\n      setup({ runningMigration: RUNNING_DOC, fetchJSON })\n      fireEvent.click(screen.getByTestId('migration-progress-banner-cancel'))\n      await waitFor(() => expect(fetchJSON).toHaveBeenCalled())\n      expect(logger.error).not.toHaveBeenCalled()\n    })\n\n    it('logs other errors', async () => {\n      const fetchJSON = jest.fn().mockRejectedValue({ status: 500 })\n      setup({ runningMigration: RUNNING_DOC, fetchJSON })\n      fireEvent.click(screen.getByTestId('migration-progress-banner-cancel'))\n      await waitFor(() => expect(logger.error).toHaveBeenCalled())\n    })\n  })\n})\n"
  },
  {
    "path": "src/components/MoreMenu.tsx",
    "content": "import React, { useState, useCallback, useRef, RefObject, FC } from 'react'\n\nimport ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'\nimport { Action } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\n\nimport { File } from './FolderPicker/types'\n\nimport MoreButton from '@/components/Button/MoreButton'\n\ninterface MoreMenuProps {\n  actions: Record<string, Action>[]\n  docs?: File[]\n  disabled?: boolean\n}\n\n/**\n * Renders a MoreMenu component.\n *\n * @param actions - The actions to be displayed in the menu.\n * @param docs - The documents to which the actions apply.\n * @param disabled - Indicates whether the menu is disabled.\n * @returns The rendered MoreMenu component.\n */\nconst MoreMenu: FC<MoreMenuProps> = ({ actions, docs = [], disabled }) => {\n  const [isMenuOpened, setMenuOpened] = useState(false)\n  const moreButtonRef: RefObject<HTMLDivElement> = useRef(null)\n  const openMenu = useCallback(() => setMenuOpened(true), [setMenuOpened])\n  const closeMenu = useCallback(() => setMenuOpened(false), [setMenuOpened])\n\n  return (\n    <>\n      <div ref={moreButtonRef}>\n        <MoreButton onClick={openMenu} disabled={disabled} />\n      </div>\n      {isMenuOpened ? (\n        <ActionsMenu\n          open\n          ref={moreButtonRef}\n          onClose={closeMenu}\n          actions={actions}\n          docs={docs}\n          anchorOrigin={{\n            strategy: 'fixed',\n            vertical: 'bottom',\n            horizontal: 'right'\n          }}\n        />\n      ) : null}\n    </>\n  )\n}\n\nexport { MoreMenu }\n"
  },
  {
    "path": "src/components/MoveValidationModals/index.tsx",
    "content": "import React from 'react'\n\nimport { useClipboardContext } from '@/contexts/ClipboardProvider'\nimport { MoveInsideSharedFolderModal } from '@/modules/move/MoveInsideSharedFolderModal'\nimport { MoveOutsideSharedFolderModal } from '@/modules/move/MoveOutsideSharedFolderModal'\nimport { MoveSharedFolderInsideAnotherModal } from '@/modules/move/MoveSharedFolderInsideAnotherModal'\n\nconst MoveValidationModals: React.FC = () => {\n  const { moveValidationModal, hideMoveValidationModal } = useClipboardContext()\n\n  if (!moveValidationModal.isVisible) {\n    return null\n  }\n\n  const handleCancel = (): void => {\n    if (moveValidationModal.onCancel) {\n      moveValidationModal.onCancel()\n    }\n    hideMoveValidationModal()\n  }\n\n  const handleConfirm = async (): Promise<void> => {\n    if (moveValidationModal.onConfirm) {\n      await moveValidationModal.onConfirm()\n    }\n    hideMoveValidationModal()\n  }\n\n  /** This component renders move validation modals (MoveOutside/Inside/SharedInside)\n   * triggered by keyboard shortcuts during cut/paste operations across different views\n   */\n  switch (moveValidationModal.type) {\n    case 'moveOutside':\n      return (\n        <MoveOutsideSharedFolderModal\n          entries={[moveValidationModal.file]}\n          onCancel={handleCancel}\n          onConfirm={handleConfirm}\n          driveId={moveValidationModal.file?.driveId}\n        />\n      )\n    case 'moveInside':\n      return (\n        <MoveInsideSharedFolderModal\n          entries={[moveValidationModal.file]}\n          folderId={moveValidationModal.targetFolder._id}\n          onCancel={handleCancel}\n          onConfirm={handleConfirm}\n          driveId={moveValidationModal.targetFolder.driveId}\n        />\n      )\n    case 'moveSharedInside':\n      return (\n        <MoveSharedFolderInsideAnotherModal\n          entries={[moveValidationModal.file]}\n          folderId={moveValidationModal.targetFolder._id}\n          onCancel={handleCancel}\n          onConfirm={handleConfirm}\n          driveId={moveValidationModal.targetFolder.driveId}\n        />\n      )\n    default:\n      return null\n  }\n}\n\nexport default MoveValidationModals\n"
  },
  {
    "path": "src/components/PushBanner/PushBanner.spec.jsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport React from 'react'\n\nimport { useInstanceInfo } from 'cozy-client'\nimport { makeDiskInfos } from 'cozy-client/dist/models/instance'\nimport { isFlagshipApp } from 'cozy-device-helper'\n\nimport PushBanner from '.'\nimport { usePushBannerContext } from './PushBannerProvider'\n\njest.mock('./QuotaBanner', () => () => <div>QuotaBanner</div>)\n\njest.mock('../pushClient/Banner', () => () => <div>BannerClient</div>)\n\njest.mock('cozy-client/dist/models/instance', () => ({\n  makeDiskInfos: jest.fn()\n}))\n\njest.mock('cozy-client', () => ({\n  ...jest.requireActual('cozy-client'),\n  useInstanceInfo: jest.fn(() => ({\n    isLoaded: true\n  }))\n}))\n\njest.mock('./PushBannerProvider', () => ({\n  usePushBannerContext: jest.fn(() => ({\n    bannerDismissed: {}\n  }))\n}))\n\njest.mock('cozy-device-helper', () => ({\n  isFlagshipApp: jest.fn(() => false)\n}))\n\ndescribe('PushBanner', () => {\n  const setup = (percentUsage = 50, dismissed = false) => {\n    usePushBannerContext.mockReturnValue({\n      bannerDismissed: {\n        quota: dismissed\n      }\n    })\n    makeDiskInfos.mockReturnValue({\n      percentUsage\n    })\n    return render(<PushBanner />)\n  }\n\n  describe('QuotaBanner', () => {\n    it('should show quota banner when disk usage has reach 80%', () => {\n      setup(80)\n      expect(screen.findByText('QuotaBanner')).toBeDefined()\n    })\n\n    it('should show client banner when disk usage is below 80%', () => {\n      setup(50)\n      expect(screen.findByText('BannerClient')).toBeDefined()\n    })\n\n    it('should show client banner when use dismiss it', () => {\n      setup(90, true)\n      expect(screen.findByText('BannerClient')).toBeDefined()\n    })\n  })\n\n  describe('BannerClient', () => {\n    it('should show client banner if the quota banner is not displayed', () => {\n      setup(80)\n      expect(screen.findByText('QuotaBanner')).toBeDefined()\n    })\n\n    it('should hide client banner on flagship app', () => {\n      isFlagshipApp.mockReturnValue(true)\n      const { container } = setup()\n      expect(container).toBeEmptyDOMElement()\n    })\n  })\n\n  it('should hide banner when is public', () => {\n    const { container } = render(<PushBanner isPublic={true} />)\n    expect(container).toBeEmptyDOMElement()\n  })\n\n  it('should hide banner when the instance information is not loaded', () => {\n    useInstanceInfo.mockReturnValue({\n      isLoaded: false\n    })\n    const { container } = render(<PushBanner />)\n    expect(container).toBeEmptyDOMElement()\n  })\n})\n"
  },
  {
    "path": "src/components/PushBanner/PushBannerProvider.jsx",
    "content": "import React, { createContext, useContext, useState } from 'react'\n\nconst PushBannerContext = createContext()\n\n/**\n * This provider allows you to hide banners while browsing the site\n */\nconst PushBannerProvider = ({ children }) => {\n  const [bannerDismissed, setBannerDimissed] = useState({\n    quota: false\n  })\n\n  const dismissPushBanner = name => {\n    if (name === 'quota') {\n      setBannerDimissed({\n        ...bannerDismissed,\n        quota: true\n      })\n    }\n  }\n\n  return (\n    <PushBannerContext.Provider value={{ bannerDismissed, dismissPushBanner }}>\n      {children}\n    </PushBannerContext.Provider>\n  )\n}\n\nexport default PushBannerProvider\n\nexport const usePushBannerContext = () => useContext(PushBannerContext)\n"
  },
  {
    "path": "src/components/PushBanner/QuotaBanner.jsx",
    "content": "import React, { useEffect, useState } from 'react'\n\nimport { useInstanceInfo } from 'cozy-client'\nimport {\n  arePremiumLinksEnabled,\n  buildPremiumLink\n} from 'cozy-client/dist/models/instance'\nimport { isFlagshipApp } from 'cozy-device-helper'\nimport flag from 'cozy-flags'\nimport { useWebviewIntent } from 'cozy-intent'\nimport Alert from 'cozy-ui/transpiled/react/Alert'\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport CloudSyncIcon from 'cozy-ui/transpiled/react/Icons/CloudSync'\nimport { useI18n } from 'twake-i18n'\n\nimport { usePushBannerContext } from './PushBannerProvider'\n\n/**\n * Banner to inform users that they have reached more than 80% of their disk space\n */\nconst QuotaBanner = () => {\n  const { t } = useI18n()\n  const { dismissPushBanner } = usePushBannerContext()\n  const instanceInfo = useInstanceInfo()\n  const webviewIntent = useWebviewIntent()\n  const [hasIAP, setIAP] = useState(false)\n\n  useEffect(() => {\n    const fetchIapAvailability = async () => {\n      const isAvailable =\n        (await webviewIntent?.call('isAvailable', 'iap')) ?? false\n      const isEnabled = !!flag('flagship.iap.enabled')\n      setIAP(isAvailable && isEnabled)\n    }\n\n    if (isFlagshipApp()) {\n      fetchIapAvailability()\n    }\n  }, [webviewIntent])\n\n  const onAction = () => {\n    const link = buildPremiumLink(instanceInfo)\n    window.open(link, '_self')\n  }\n\n  const onDismiss = () => {\n    dismissPushBanner('quota')\n  }\n\n  const canOpenPremiumLink =\n    arePremiumLinksEnabled(instanceInfo) && (!isFlagshipApp() || hasIAP)\n\n  return (\n    <div className=\"u-pos-relative\">\n      <Alert\n        icon={<Icon icon={CloudSyncIcon} />}\n        color=\"var(--defaultBackgroundColor)\"\n        action={\n          <>\n            <Button\n              label={t('PushBanner.quota.actions.first')}\n              variant=\"text\"\n              onClick={onDismiss}\n            />\n\n            {canOpenPremiumLink ? (\n              <Button\n                label={t('PushBanner.quota.actions.second')}\n                variant=\"text\"\n                onClick={onAction}\n              />\n            ) : null}\n          </>\n        }\n      >\n        {t('PushBanner.quota.text')}\n      </Alert>\n    </div>\n  )\n}\n\nexport default QuotaBanner\n"
  },
  {
    "path": "src/components/PushBanner/QuotaBanner.spec.jsx",
    "content": "import { fireEvent, render, screen } from '@testing-library/react'\nimport React from 'react'\n\nimport { useInstanceInfo } from 'cozy-client'\nimport { isFlagshipApp } from 'cozy-device-helper'\nimport flag from 'cozy-flags'\nimport { useWebviewIntent } from 'cozy-intent'\n\nimport { usePushBannerContext } from './PushBannerProvider'\nimport QuotaBanner from './QuotaBanner'\nimport { TestI18n } from 'test/components/AppLike'\n\njest.mock('./PushBannerProvider', () => ({\n  usePushBannerContext: jest.fn()\n}))\njest.mock('cozy-device-helper', () => ({\n  ...jest.requireActual('cozy-device-helper'),\n  isFlagshipApp: jest.fn()\n}))\njest.mock('cozy-flags')\njest.mock('cozy-client', () => ({\n  ...jest.requireActual('cozy-client'),\n  useInstanceInfo: jest.fn(() => ({\n    isLoaded: true\n  }))\n}))\njest.mock('cozy-intent', () => ({\n  ...jest.requireActual('cozy-intent'),\n  useWebviewIntent: jest.fn()\n}))\n\ndescribe('QuotaBanner', () => {\n  const dismissSpy = jest.fn()\n  const openSpy = jest.spyOn(window, 'open').mockImplementation()\n\n  beforeEach(() => {\n    jest.clearAllMocks()\n  })\n\n  const setup = ({\n    enablePremiumLinks = false,\n    hasUuid = false,\n    isFlagshipApp: isFlagshipAppReturnValue = false,\n    isIapEnabled = null,\n    isIapAvailable = null\n  } = {}) => {\n    usePushBannerContext.mockReturnValue({\n      dismissPushBanner: dismissSpy\n    })\n\n    useInstanceInfo.mockReturnValue({\n      context: {\n        data: {\n          enable_premium_links: enablePremiumLinks,\n          manager_url: 'http://mycozy.cloud'\n        }\n      },\n      instance: {\n        data: {\n          uuid: hasUuid ? '123' : null\n        }\n      }\n    })\n\n    isFlagshipApp.mockReturnValue(isFlagshipAppReturnValue)\n    flag.mockReturnValue(isIapEnabled)\n    const mockCall = jest.fn().mockResolvedValue(isIapAvailable)\n    useWebviewIntent.mockReturnValue({\n      call: mockCall\n    })\n\n    render(\n      <TestI18n>\n        <QuotaBanner />\n      </TestI18n>\n    )\n  }\n\n  it('should display \"I understand\" button and close the banner', () => {\n    setup()\n\n    fireEvent.click(screen.getByRole('button', { name: 'I understand' }))\n    expect(dismissSpy).toHaveBeenCalledTimes(1)\n\n    const premiumButton = screen.queryByText('Check our plans')\n    expect(premiumButton).toBeNull()\n  })\n\n  it('should hide premium link when there is a premium link but it is not enabled', () => {\n    setup({\n      hasUuid: true\n    })\n\n    const premiumButton = screen.queryByText('Check our plans')\n    expect(premiumButton).toBeNull()\n  })\n\n  it('should display premium link when the premium link is enabled and available', () => {\n    setup({\n      hasUuid: true,\n      enablePremiumLinks: true\n    })\n\n    fireEvent.click(screen.getByText('Check our plans'))\n    expect(openSpy).toHaveBeenCalledWith(\n      'http://mycozy.cloud/cozy/instances/123/premium?lang=en',\n      '_self'\n    )\n  })\n\n  it('should hide premium link when is on flagship application', () => {\n    setup({\n      hasUuid: true,\n      enablePremiumLinks: true,\n      isFlagshipApp: true\n    })\n\n    const premiumButton = screen.queryByText('Check our plans')\n    expect(premiumButton).toBeNull()\n  })\n\n  it('should hide premium link when the flagship app has not IAP available with the flag flagship.iap.enabled is false', () => {\n    setup({\n      hasUuid: true,\n      enablePremiumLinks: true,\n      isFlagshipApp: true,\n      isIapEnabled: false,\n      isIapAvailable: false\n    })\n\n    const premiumButton = screen.queryByText('Check our plans')\n    expect(premiumButton).toBeNull()\n  })\n\n  it('should hide premium link when the flagship app has not IAP available with the flag flagship.iap.enabled is true', () => {\n    setup({\n      hasUuid: true,\n      enablePremiumLinks: true,\n      isFlagshipApp: true,\n      isIapEnabled: true,\n      isIapAvailable: false\n    })\n\n    const premiumButton = screen.queryByText('Check our plans')\n    expect(premiumButton).toBeNull()\n  })\n\n  it('should display premium link when the flagship app has IAP available with the flag flagship.iap.enabled is true', async () => {\n    setup({\n      hasUuid: true,\n      enablePremiumLinks: true,\n      isFlagshipApp: true,\n      isIapEnabled: true,\n      isIapAvailable: true\n    })\n\n    const actionButton = await screen.findByText('Check our plans')\n\n    fireEvent.click(actionButton)\n    expect(openSpy).toHaveBeenCalledWith(\n      'http://mycozy.cloud/cozy/instances/123/premium?lang=en',\n      '_self'\n    )\n  })\n})\n"
  },
  {
    "path": "src/components/PushBanner/index.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { useInstanceInfo } from 'cozy-client'\nimport { makeDiskInfos } from 'cozy-client/dist/models/instance'\nimport { isFlagshipApp } from 'cozy-device-helper'\n\nimport { usePushBannerContext } from './PushBannerProvider'\nimport QuotaBanner from './QuotaBanner'\nimport BannerClient from '../../components/pushClient/Banner'\n\n/**\n * Component to manage all banner display logic\n */\nconst PushBanner = ({ isPublic }) => {\n  const { bannerDismissed } = usePushBannerContext()\n  const { isLoaded, diskUsage } = useInstanceInfo()\n\n  if (isPublic || !isLoaded) return null\n\n  const diskInfos = makeDiskInfos(\n    diskUsage?.data?.attributes?.used,\n    diskUsage?.data?.attributes?.quota\n  )\n\n  if (!bannerDismissed.quota && diskInfos.percentUsage >= 80) {\n    return <QuotaBanner />\n  }\n\n  if (!isFlagshipApp()) {\n    return <BannerClient />\n  }\n\n  return null\n}\n\nPushBanner.defaultProps = {\n  isPublic: false\n}\n\nPushBanner.propTypes = {\n  /** Whether public context */\n  isPublic: PropTypes.bool\n}\n\nexport default PushBanner\n"
  },
  {
    "path": "src/components/RightClick/RightClickAddMenu.jsx",
    "content": "import React, { useContext } from 'react'\nimport { useLocation } from 'react-router-dom'\n\nimport { useSharingContext } from 'cozy-sharing'\nimport { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { useRightClick } from '@/components/RightClick/RightClickProvider'\nimport { useDisplayedFolder } from '@/hooks'\nimport AddMenuProvider, {\n  AddMenuContext\n} from '@/modules/drive/AddMenu/AddMenuProvider'\n\nconst AddMenu = ({ children, ...props }) => {\n  const { isDesktop } = useBreakpoints()\n  const { onOpen } = useRightClick()\n  const { handleToggle, handleOfflineClick, isOffline } =\n    useContext(AddMenuContext)\n  const location = useLocation()\n\n  const isInViewerMode = location.pathname.includes('/file/')\n\n  if (!children) return null\n  if (!isDesktop || isInViewerMode)\n    return React.Children.map(children, child =>\n      React.isValidElement(child)\n        ? React.cloneElement(child, {\n            ...props\n          })\n        : null\n    )\n\n  return React.Children.map(children, child =>\n    React.isValidElement(child)\n      ? React.cloneElement(child, {\n          ...props,\n          onContextMenu: ev => {\n            if (isOffline) {\n              handleOfflineClick()\n            } else {\n              onOpen(ev, `AddMenu`)\n              handleToggle()\n            }\n          }\n        })\n      : null\n  )\n}\n\nconst RightClickAddMenu = ({ children, ...props }) => {\n  const { isOpen, position } = useRightClick()\n  const { displayedFolder } = useDisplayedFolder()\n  const { hasWriteAccess } = useSharingContext()\n  const location = useLocation()\n\n  const isFolderReadOnly = displayedFolder\n    ? !hasWriteAccess(displayedFolder._id, displayedFolder.driveId)\n    : false\n\n  const isInViewerMode = location.pathname.includes('/file/')\n  const shouldShowAddMenu = isOpen('AddMenu') && !isInViewerMode\n\n  return (\n    <AddMenuProvider\n      canCreateFolder={true}\n      canUpload={true}\n      disabled={false}\n      displayedFolder={displayedFolder}\n      isSelectionBarVisible={false}\n      isReadOnly={isFolderReadOnly}\n      componentsProps={{\n        AddMenu: {\n          anchorReference: 'anchorPosition',\n          anchorPosition: shouldShowAddMenu\n            ? { top: position.mouseY, left: position.mouseX }\n            : undefined\n        }\n      }}\n    >\n      <AddMenu {...props}>{children}</AddMenu>\n    </AddMenuProvider>\n  )\n}\n\nexport default RightClickAddMenu\n"
  },
  {
    "path": "src/components/RightClick/RightClickFileMenu.jsx",
    "content": "import React from 'react'\n\nimport ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'\nimport { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { useRightClick } from '@/components/RightClick/RightClickProvider'\nimport { getContextMenuActions } from '@/modules/actions/helpers'\nimport { filterActionsByPolicy } from '@/modules/actions/policies'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\n\nconst RightClickFileMenu = ({\n  doc,\n  actions,\n  disabled,\n  children,\n  prefixMenuId,\n  ...props\n}) => {\n  const { position, isOpen, onOpen, onClose } = useRightClick()\n  const { isDesktop } = useBreakpoints()\n  const { selectedItems, isItemSelected, setSelectedItems, showSelectionBar } =\n    useSelectionContext()\n\n  const targetFiles = isItemSelected(doc._id)\n    ? Object.values(selectedItems)\n    : [doc]\n\n  const contextMenuActions = filterActionsByPolicy(\n    getContextMenuActions(actions),\n    targetFiles\n  )\n\n  if (!children) return null\n  if (disabled || !isDesktop)\n    return React.Children.map(children, child =>\n      React.isValidElement(child)\n        ? React.cloneElement(child, {\n            ...props\n          })\n        : null\n    )\n\n  return (\n    <>\n      {React.Children.map(children, child =>\n        React.isValidElement(child)\n          ? React.cloneElement(child, {\n              ...props,\n              onContextMenu: ev => {\n                // If the file is not already selected, select it (clearing other selections)\n                if (!isItemSelected(doc._id)) {\n                  setSelectedItems({ [doc._id]: doc })\n                  showSelectionBar()\n                }\n                onOpen(ev, `${prefixMenuId ?? 'FileMenu'}-${doc._id}`)\n                ev.preventDefault()\n                ev.stopPropagation()\n              }\n            })\n          : null\n      )}\n      {isOpen(`${prefixMenuId ?? 'FileMenu'}-${doc._id}`) && (\n        <ActionsMenu\n          open\n          docs={targetFiles}\n          actions={contextMenuActions}\n          anchorReference=\"anchorPosition\"\n          anchorPosition={{ top: position.mouseY, left: position.mouseX }}\n          autoClose\n          onClose={onClose}\n        />\n      )}\n    </>\n  )\n}\n\nexport default RightClickFileMenu\n"
  },
  {
    "path": "src/components/RightClick/RightClickProvider.jsx",
    "content": "import React, {\n  useContext,\n  useState,\n  createContext,\n  useMemo,\n  useCallback\n} from 'react'\n\nconst initialPosition = {\n  mouseX: null,\n  mouseY: null\n}\n\nconst RightClickContext = createContext()\n\nexport const useRightClick = () => {\n  const context = useContext(RightClickContext)\n\n  if (!context) {\n    throw new Error('useRightClick must be used within a RightClickProvider')\n  }\n  return context\n}\n\nconst RightClickProvider = ({ children }) => {\n  const [position, setPosition] = useState(initialPosition)\n  const [id, setId] = useState('')\n\n  const onOpen = useCallback((ev, eventId) => {\n    ev.preventDefault()\n    ev.stopPropagation()\n\n    setId(eventId)\n    setPosition({\n      mouseX: ev.clientX - 2,\n      mouseY: ev.clientY - 4\n    })\n  }, [])\n\n  const onClose = () => setPosition(initialPosition)\n\n  const value = useMemo(\n    () => ({\n      position,\n      isOpen: attr =>\n        attr === id && position.mouseY !== null && position.mouseX !== null,\n      onOpen,\n      onClose\n    }),\n    [position, id, onOpen]\n  )\n\n  return (\n    <RightClickContext.Provider value={value}>\n      {children}\n    </RightClickContext.Provider>\n  )\n}\n\nexport default RightClickProvider\n"
  },
  {
    "path": "src/components/SideBarAccordion.jsx",
    "content": "import React, { useState } from 'react'\n\nimport Accordion from 'cozy-ui/transpiled/react/Accordion'\nimport AccordionDetails from 'cozy-ui/transpiled/react/AccordionDetails'\nimport AccordionSummary from 'cozy-ui/transpiled/react/AccordionSummary'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport BottomIcon from 'cozy-ui/transpiled/react/Icons/Bottom'\nimport { makeStyles } from 'cozy-ui/transpiled/react/styles'\n\nconst useStyles = makeStyles({\n  accordion: {\n    boxShadow: 'none',\n    border: 'none',\n    backgroundColor: 'transparent',\n    marginTop: '0 !important'\n  },\n  summary: {\n    textTransform: 'none',\n    color: 'var(--coolGrey)',\n    minHeight: '0 !important',\n    border: '0 !important'\n  }\n})\n\nexport const SideBarAccordion = ({\n  title,\n  children,\n  defaultExpanded = true,\n  childrenCount,\n  childrenLimit = 5\n}) => {\n  const [isExpanded, setIsExpanded] = useState(defaultExpanded)\n  const classes = useStyles()\n  const shouldShowExpand = childrenCount > childrenLimit\n\n  const handleChange = () => {\n    setIsExpanded(!isExpanded)\n  }\n\n  return (\n    <Accordion\n      className={classes.accordion}\n      defaultExpanded={defaultExpanded}\n      elevation={0}\n      onChange={shouldShowExpand ? handleChange : undefined}\n      expanded={!shouldShowExpand || isExpanded}\n    >\n      <AccordionSummary className={classes.summary} expandIcon={null}>\n        {title}\n        {shouldShowExpand && (\n          <Icon\n            className=\"u-mh-half\"\n            icon={BottomIcon}\n            rotate={isExpanded ? 0 : -90}\n          />\n        )}\n      </AccordionSummary>\n      <AccordionDetails className=\"u-bdw-0\">{children}</AccordionDetails>\n    </Accordion>\n  )\n}\n"
  },
  {
    "path": "src/components/TrashedBanner.jsx",
    "content": "import React, { useState } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useClient, useQuery } from 'cozy-client'\nimport Alert from 'cozy-ui/transpiled/react/Alert'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport TrashDuotoneIcon from 'cozy-ui/transpiled/react/Icons/TrashDuotone'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport logger from '@/lib/logger'\nimport DestroyConfirm from '@/modules/trash/components/DestroyConfirm'\nimport { buildFileOrFolderByIdQuery } from '@/queries'\n\nconst TrashedBanner = ({ fileId, isPublic }) => {\n  const { t } = useI18n()\n  const client = useClient()\n  const navigate = useNavigate()\n  const { showAlert } = useAlert()\n  const { isMobile } = useBreakpoints()\n\n  const fileQuery = buildFileOrFolderByIdQuery(fileId)\n  const fileResult = useQuery(fileQuery.definition, fileQuery.options)\n\n  const [isBusy, setBusy] = useState(false)\n  const [isDestroyConfirmationDisplayed, setDestroyConfirmationDisplayed] =\n    useState(false)\n\n  const restore = async () => {\n    try {\n      await client.collection('io.cozy.files').restore(fileId)\n      showAlert({\n        message: t('TrashedBanner.restoreSuccess'),\n        severity: 'secondary'\n      })\n    } catch (e) {\n      logger.warn(`Error while restoring file ${fileId}`, e)\n      showAlert({ message: t('TrashedBanner.restoreError'), severity: 'error' })\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  const destroy = async () => {\n    setDestroyConfirmationDisplayed(true)\n  }\n\n  const handleDestroyCancel = () => {\n    setDestroyConfirmationDisplayed(false)\n  }\n\n  const handleDestroyConfirm = async () => {\n    await client?.collection('io.cozy.files').deleteFilePermanently(fileId)\n    showAlert({\n      message: t('TrashedBanner.destroySuccess'),\n      severity: 'secondary'\n    })\n  }\n\n  return (\n    <>\n      <Alert\n        square\n        severity=\"secondary\"\n        icon={<Icon icon={TrashDuotoneIcon} size={32} />}\n        block={isMobile}\n        action={\n          !isPublic ? (\n            <>\n              <Buttons\n                size=\"small\"\n                variant=\"text\"\n                label={t('TrashedBanner.restore')}\n                onClick={restore}\n                busy={isBusy}\n              />\n              <Buttons\n                size=\"small\"\n                variant=\"text\"\n                label={t('TrashedBanner.destroy')}\n                onClick={destroy}\n                disabled={isBusy}\n              />\n            </>\n          ) : null\n        }\n      >\n        {t('TrashedBanner.text')}\n      </Alert>\n      {isDestroyConfirmationDisplayed ? (\n        <DestroyConfirm\n          files={[fileResult.data]}\n          onCancel={handleDestroyCancel}\n          onConfirm={handleDestroyConfirm}\n          onClose={navigate(`/trash/${fileResult.data.dir_id}`)}\n        />\n      ) : null}\n    </>\n  )\n}\n\nexport { TrashedBanner }\n"
  },
  {
    "path": "src/components/pushClient/Banner.jsx",
    "content": "// eslint-disable-next-line no-redeclare,no-unused-vars\n/* global localStorage */\n\nimport localforage from 'localforage'\nimport flow from 'lodash/flow'\nimport React, { Component } from 'react'\n\nimport { withClient } from 'cozy-client'\nimport Alert from 'cozy-ui/transpiled/react/Alert'\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport DesktopDownloadIcon from 'cozy-ui/transpiled/react/Icons/DesktopDownload'\nimport DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download'\nimport PhoneDownloadIcon from 'cozy-ui/transpiled/react/Icons/PhoneDownload'\nimport { translate } from 'twake-i18n'\n\nimport {\n  getMobileAppDownloadLink,\n  getDesktopAppDownloadLink,\n  isClientAlreadyInstalled,\n  isAndroid,\n  isIOS,\n  DESKTOP_BANNER\n} from '@/components/pushClient'\nimport Config from '@/config/config.json'\n\nclass BannerClient extends Component {\n  state = {\n    mustShow: false\n  }\n  constructor(props) {\n    super(props)\n    this.willUnmount = false\n  }\n\n  async componentDidMount() {\n    this.willUnmount = false\n    const seen = (await localforage.getItem(DESKTOP_BANNER)) || false\n    if (!seen) {\n      const mustSee = !(await isClientAlreadyInstalled(this.props.client))\n      if (mustSee && !this.willUnmount) {\n        this.setState({ mustShow: true })\n      }\n    }\n  }\n  componentWillUnmount() {\n    this.willUnmount = true\n  }\n\n  markAsSeen() {\n    localforage.setItem(DESKTOP_BANNER, true)\n    this.setState({ mustShow: false })\n  }\n\n  render() {\n    if (Config.promoteDesktop.isActivated !== true || !this.state.mustShow)\n      return null\n\n    const { t } = this.props\n\n    const isMobile = isIOS() || isAndroid()\n    const text = isMobile ? 'Nav.btn-client-mobile' : 'Nav.banner-txt-client'\n    const link = isMobile\n      ? getMobileAppDownloadLink({ t })\n      : getDesktopAppDownloadLink({ t })\n\n    return (\n      <div className=\"u-pos-relative\">\n        <Alert\n          square\n          icon={\n            <Icon\n              className=\"u-mt-1 u-ml-1\"\n              icon={isMobile ? PhoneDownloadIcon : DesktopDownloadIcon}\n              color=\"var(--primaryTextColor)\"\n              size={isMobile ? 24 : 20}\n            />\n          }\n          color=\"var(--defaultBackgroundColor)\"\n          action={\n            <>\n              <Button\n                component=\"a\"\n                variant=\"text\"\n                label={t('Nav.banner-btn-client')}\n                size=\"small\"\n                onClick={() => this.markAsSeen()}\n                startIcon={<Icon icon={DownloadIcon} />}\n                target=\"_blank\"\n                rel=\"noopener noreferrer\"\n                href={link}\n              />\n              <Button\n                variant=\"text\"\n                label={t('SelectionBar.close')}\n                size=\"small\"\n                onClick={() => this.markAsSeen()}\n              />\n            </>\n          }\n        >\n          {t(text, {\n            name: 'Twake Drive'\n          })}\n        </Alert>\n      </div>\n    )\n  }\n}\n\nexport default flow(translate(), withClient)(BannerClient)\n"
  },
  {
    "path": "src/components/pushClient/Button.jsx",
    "content": "import localforage from 'localforage'\nimport React, { useState, useEffect } from 'react'\n\nimport { withClient } from 'cozy-client'\nimport { isFlagshipApp } from 'cozy-device-helper'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport CrossSmallIcon from 'cozy-ui/transpiled/react/Icons/CrossSmall'\nimport DriveIcon from 'cozy-ui/transpiled/react/Icons/Drive'\nimport ListItem from 'cozy-ui/transpiled/react/ListItem'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport Paper from 'cozy-ui/transpiled/react/Paper'\nimport { translate } from 'twake-i18n'\n\nimport {\n  getDesktopAppDownloadLink,\n  isClientAlreadyInstalled,\n  DESKTOP_SMALL_BANNER,\n  DESKTOP_BANNER\n} from '@/components/pushClient'\nimport Config from '@/config/config.json'\n\nconst ButtonClient = ({ client, t }) => {\n  const [mustShow, setMustShow] = useState(false)\n\n  useEffect(() => {\n    const checkShouldShowButton = async () => {\n      if (Config.promoteDesktop.isActivated !== true || isFlagshipApp()) return\n\n      const hasBannerBeenClosed =\n        (await localforage.getItem(DESKTOP_BANNER)) || false\n\n      // we want to show the button if the banner has been marked as seen *and*\n      // the client hasn't been already installed\n      if (hasBannerBeenClosed) {\n        const hasClientBeenInstalled = await isClientAlreadyInstalled(client)\n\n        const hasSmallBannerBeenClosed =\n          (await localforage.getItem(DESKTOP_SMALL_BANNER)) || false\n\n        if (!hasClientBeenInstalled && !hasSmallBannerBeenClosed) {\n          setMustShow(true)\n        }\n      }\n    }\n\n    checkShouldShowButton()\n  }, [client])\n\n  const handleClick = ev => {\n    ev.stopPropagation()\n    setMustShow(false)\n    localforage.setItem(DESKTOP_SMALL_BANNER, true)\n  }\n\n  if (\n    Config.promoteDesktop.isActivated !== true ||\n    !mustShow ||\n    isFlagshipApp()\n  )\n    return null\n\n  const link = getDesktopAppDownloadLink({ t })\n\n  return (\n    <Paper\n      elevation={10}\n      className=\"u-pos-relative u-mh-1-half u-mb-1-half u-c-pointer\"\n      style={{ backgroundColor: 'var(--defaultBackgroundColor)' }}\n      onClick={() => window.open(link)}\n    >\n      <IconButton\n        className=\"u-top-0 u-right-0\"\n        style={{ position: 'absolute', zIndex: 1 }}\n        size=\"small\"\n        onClick={handleClick}\n      >\n        <Icon icon={CrossSmallIcon} size={8} />\n      </IconButton>\n      <ListItem component=\"div\">\n        <ListItemIcon>\n          <Icon icon={DriveIcon} size={32} />\n        </ListItemIcon>\n        <ListItemText\n          primaryTypographyProps={{\n            variant: 'overline',\n            color: 'textPrimary'\n          }}\n          primary=\"Twake Drive App\"\n          secondaryTypographyProps={{\n            variant: 'overline',\n            color: 'primary'\n          }}\n          secondary={t('Nav.banner-btn-client')}\n        />\n      </ListItem>\n    </Paper>\n  )\n}\n\nexport default translate()(withClient(ButtonClient))\n"
  },
  {
    "path": "src/components/pushClient/__mocks__/index.js",
    "content": "export const track = jest.fn()\n\nexport const isLinux = jest.fn()\nexport const isAndroid = jest.fn()\nexport const isIOS = jest.fn()\n\nexport const DESKTOP_BANNER = 'desktop_banner'\nexport const NOVIEWER_DESKTOP_CTA = 'noviewer_desktop_cta'\n\nexport const isClientAlreadyInstalled = jest.fn().mockResolvedValue(false)\n"
  },
  {
    "path": "src/components/pushClient/index.d.ts",
    "content": "export declare const DESKTOP_SOFTWARE_ID: string\nexport declare const DESKTOP_BANNER: string\nexport declare const DESKTOP_SMALL_BANNER: string\nexport declare const NOVIEWER_DESKTOP_CTA: string\n\nexport declare function isLinux(): boolean\nexport declare function isMacOS(): boolean\nexport declare function isAndroid(): boolean\nexport declare function isIOS(): boolean\nexport declare function isClientAlreadyInstalled(\n  client: unknown\n): Promise<boolean>\nexport declare function getDesktopAppDownloadLink(options: {\n  t: (key: string) => string\n}): string\nexport declare function getMobileAppDownloadLink(options: {\n  t: (key: string) => string\n}): string\n"
  },
  {
    "path": "src/components/pushClient/index.js",
    "content": "import CozyClient, { Q } from 'cozy-client'\nimport flag from 'cozy-flags'\n\nexport const DESKTOP_SOFTWARE_ID = 'github.com/cozy-labs/cozy-desktop'\n\nexport const isLinux = () =>\n  window.navigator &&\n  window.navigator.appVersion.indexOf('Win') === -1 &&\n  window.navigator.appVersion.indexOf('Mac') === -1\n\nexport const isMacOS = () =>\n  window.navigator && /Mac/.test(window.navigator.platform)\n\nexport const isAndroid = () =>\n  window.navigator.userAgent &&\n  window.navigator.userAgent.indexOf('Android') >= 0\n\nexport const isIOS = () =>\n  window.navigator.userAgent &&\n  /iPad|iPhone|iPod/.test(window.navigator.userAgent)\n\nexport const DESKTOP_BANNER = 'desktop_banner'\nexport const DESKTOP_SMALL_BANNER = 'desktop_small_banner'\nexport const NOVIEWER_DESKTOP_CTA = 'noviewer_desktop_cta'\n\nexport const isClientAlreadyInstalled = async client => {\n  const query = {\n    definition: Q('io.cozy.settings').getById('io.cozy.settings.clients'),\n    options: {\n      as: 'io.cozy.settings/io.cozy.settings.clients',\n      fetchPolicy: CozyClient.fetchPolicies.olderThan(30 * 1000),\n      singleDocData: true\n    }\n  }\n  const { data } = await client.fetchQueryAndGetFromState(query)\n  if (!data) {\n    return false\n  }\n  return Object.values(data).some(\n    device => device?.software_id === DESKTOP_SOFTWARE_ID\n  )\n}\n\nexport const getDesktopAppDownloadLink = ({ t }) => {\n  const desktopAppDownloadLinkFromFlag = flag('cozy.desktop-app-download-link')\n\n  if (desktopAppDownloadLinkFromFlag) {\n    return desktopAppDownloadLinkFromFlag\n  } else if (isLinux()) {\n    return t('Nav.link-client')\n  } else {\n    return t('Nav.link-client-desktop')\n  }\n}\n\nexport const getMobileAppDownloadLink = ({ t }) => {\n  if (isIOS()) {\n    return t('Nav.link-client-ios')\n  } else if (isAndroid()) {\n    return t('Nav.link-client-android')\n  } else {\n    return t('Nav.link-client')\n  }\n}\n"
  },
  {
    "path": "src/components/pushClient/index.spec.js",
    "content": "import CozyClient from 'cozy-client'\n\nimport { isClientAlreadyInstalled, DESKTOP_SOFTWARE_ID } from './index'\n\ndescribe('isClientAlreadyInstalled', () => {\n  test('isClientAlreadyInstalled is true', async () => {\n    const client = new CozyClient({})\n    client.fetchQueryAndGetFromState = jest.fn().mockResolvedValue({\n      data: {\n        0: {\n          software_id: DESKTOP_SOFTWARE_ID\n        }\n      }\n    })\n    const isInstalled = await isClientAlreadyInstalled(client)\n    expect(isInstalled).toBe(true)\n  })\n  test('isClientAlreadyInstalled is not installed', async () => {\n    const client = new CozyClient({})\n    client.fetchQueryAndGetFromState = jest.fn().mockResolvedValue({\n      data: {\n        0: {\n          software_id: test\n        }\n      }\n    })\n    const isInstalled = await isClientAlreadyInstalled(client)\n    expect(isInstalled).toBe(false)\n  })\n})\n"
  },
  {
    "path": "src/components/useDocument.jsx",
    "content": "import { useMemo } from 'react'\nimport { useSelector } from 'react-redux'\n\nimport { useClient } from 'cozy-client'\nimport { getDocumentFromState } from 'cozy-client/dist/store'\n\nconst useDocument = (doctype, id) => {\n  const client = useClient()\n  const doc = useSelector(state => {\n    if (id) return getDocumentFromState(state, doctype, id)\n    return undefined\n  })\n  return useMemo(() => client.hydrateDocument(doc), [client, doc])\n}\n\nexport default useDocument\n"
  },
  {
    "path": "src/components/useHead.jsx",
    "content": "import { useMemo } from 'react'\nimport { useParams } from 'react-router-dom'\n\nimport { useQuery } from 'cozy-client'\n\nimport useUpdateFavicon from '@/hooks/useUpdateFavicon'\nimport useUpdateDocumentTitle from '@/modules/views/useUpdateDocumentTitle'\nimport {\n  buildFileOrFolderByIdQuery,\n  buildSharedDriveFolderQuery\n} from '@/queries'\n\nconst useHead = ({ title } = {}) => {\n  const { driveId, folderId, fileId } = useParams()\n\n  const isFileOpen = useMemo(() => fileId !== undefined, [fileId])\n\n  const fileQuery = driveId\n    ? buildSharedDriveFolderQuery({\n        driveId,\n        folderId: isFileOpen ? fileId : folderId\n      })\n    : buildFileOrFolderByIdQuery(isFileOpen ? fileId : folderId)\n  const { data: file, fetchStatus } = useQuery(\n    fileQuery.definition,\n    fileQuery.options\n  )\n\n  useUpdateDocumentTitle(file, fetchStatus, title)\n  useUpdateFavicon(file, fetchStatus)\n}\n\nexport default useHead\n"
  },
  {
    "path": "src/config/config.json",
    "content": "{\n  \"promoteDesktop\": {\n    \"isActivated\": true\n  }\n}\n"
  },
  {
    "path": "src/config/sort.js",
    "content": "export const SORTABLE_ATTRIBUTES = [\n  { label: 'name', attr: 'name', css: 'file', defaultOrder: 'asc' },\n  { label: 'update', attr: 'updated_at', css: 'date', defaultOrder: 'desc' }\n  // TODO: activate sorting by size when it's ready on the back side\n  // { label: 'size', attr: 'size', css: 'size', defaultOrder: 'desc' }\n]\nexport const DEFAULT_SORT = { attribute: 'name', order: 'asc' }\nexport const SORT_BY_UPDATE_DATE = { attribute: 'updated_at', order: 'desc' }\n"
  },
  {
    "path": "src/constants/config.js",
    "content": "/**\n  Redux constants\n**/\n\n// global variables\nexport const ROOT_DIR_ID = 'io.cozy.files.root-dir'\nexport const TRASH_DIR_ID = 'io.cozy.files.trash-dir'\nexport const SHAREDWITHME_DIR_ID = 'io.cozy.files.shared-with-me-dir'\nexport const SHARED_DRIVES_DIR_ID = 'io.cozy.files.shared-drives-dir' // This folder mostly contains external drives like Nextcloud\nexport const SHARED_DRIVES_DIR_PATH = 'io.cozy.files.shared-drives-dir'\nexport const SETTINGS_DIR_PATH = '/Settings'\nexport const NEXTCLOUD_FILE_ID = 'io.cozy.remote.nextcloud.files'\nexport const RECENT_FOLDER_ID = 'recent'\nexport const APPS_DIR_PATH = '/.cozy_apps'\nexport const TRASH_DIR_PATH = '/.cozy_trash'\nexport const KONNECTORS_DIR_PATH = '/.cozy_konnectors'\nexport const FILES_FETCH_LIMIT = 100\nexport const MAX_PAYLOAD_SIZE_IN_GB = 5\nexport const MAX_PAYLOAD_SIZE = MAX_PAYLOAD_SIZE_IN_GB * 1024 * 1024 * 1024\nexport const MAX_UPLOAD_FILE_COUNT = 500\nexport const SHARING_TAB_ALL = 0\nexport const SHARING_TAB_DRIVES = 1\nexport const DEFAULT_UPLOAD_PROGRESS_HIDE_DELAY = 5000\nexport const SHARINGS_VIEW_ROUTE = '/sharings'\n"
  },
  {
    "path": "src/contexts/ClipboardProvider.spec.tsx",
    "content": "import '@testing-library/jest-dom'\nimport {\n  render,\n  screen,\n  fireEvent,\n  renderHook,\n  act as actHook\n} from '@testing-library/react'\nimport React from 'react'\n\nimport { IOCozyFile } from 'cozy-client/types/types'\n\nimport ClipboardProvider, { useClipboardContext } from './ClipboardProvider'\n\nconst mockFile1: IOCozyFile = {\n  _id: 'file1',\n  _rev: '1-abc',\n  _type: 'io.cozy.files',\n  name: 'test1.txt',\n  type: 'file',\n  dir_id: 'parent-folder',\n  created_at: '2023-01-01T00:00:00.000Z',\n  updated_at: '2023-01-01T00:00:00.000Z',\n  size: 1024,\n  md5sum: 'abc123',\n  mime: 'text/plain',\n  class: 'text',\n  executable: false,\n  attributes: { name: 'test1.txt' },\n  metadata: {}\n} as unknown as IOCozyFile\n\nconst mockFile2: IOCozyFile = {\n  _id: 'file2',\n  _rev: '1-def',\n  _type: 'io.cozy.files',\n  name: 'test2.txt',\n  type: 'file',\n  dir_id: 'parent-folder',\n  created_at: '2023-01-01T00:00:00.000Z',\n  updated_at: '2023-01-01T00:00:00.000Z',\n  size: 2048,\n  md5sum: 'def456',\n  mime: 'text/plain',\n  class: 'text',\n  executable: false,\n  attributes: { name: 'test2.txt' },\n  metadata: {}\n} as unknown as IOCozyFile\n\nconst mockFolder: IOCozyFile = {\n  _id: 'folder1',\n  _rev: '1-ghi',\n  _type: 'io.cozy.files',\n  name: 'Test Folder',\n  type: 'directory',\n  dir_id: 'parent-folder',\n  created_at: '2023-01-01T00:00:00.000Z',\n  updated_at: '2023-01-01T00:00:00.000Z',\n  attributes: { name: 'Test Folder' },\n  metadata: {}\n} as unknown as IOCozyFile\n\nconst TestComponent = (): JSX.Element => {\n  const {\n    clipboardData,\n    copyFiles,\n    cutFiles,\n    clearClipboard,\n    hasClipboardData,\n    isItemCut,\n    showMoveValidationModal,\n    hideMoveValidationModal,\n    moveValidationModal\n  } = useClipboardContext()\n\n  return (\n    <div>\n      <div data-testid=\"clipboard-files-count\">\n        {clipboardData.files.length}\n      </div>\n      <div data-testid=\"clipboard-operation\">\n        {clipboardData.operation ?? 'none'}\n      </div>\n      <div data-testid=\"has-clipboard-data\">{hasClipboardData.toString()}</div>\n      <div data-testid=\"cut-item-ids\">\n        {Array.from(clipboardData.cutItemIds).join(',')}\n      </div>\n      <div data-testid=\"source-folder-id\">\n        {clipboardData.sourceFolderIds && clipboardData.sourceFolderIds.size > 0\n          ? Array.from(clipboardData.sourceFolderIds).join(',')\n          : 'none'}\n      </div>\n      <div data-testid=\"modal-visible\">\n        {moveValidationModal.isVisible.toString()}\n      </div>\n      <div data-testid=\"modal-type\">{moveValidationModal.type ?? 'none'}</div>\n\n      <button\n        onClick={(): void =>\n          copyFiles([mockFile1, mockFile2], new Set(['source-folder']))\n        }\n      >\n        Copy Files\n      </button>\n      <button\n        onClick={(): void => cutFiles([mockFile1], new Set(['source-folder']))}\n      >\n        Cut Files\n      </button>\n      <button onClick={clearClipboard}>Clear Clipboard</button>\n      <button\n        onClick={(): void =>\n          showMoveValidationModal(\n            'moveOutside',\n            mockFile1,\n            mockFolder,\n            async (): Promise<void> => {\n              // Empty async function for test\n            },\n            (): void => {\n              // Empty function for test\n            }\n          )\n        }\n      >\n        Show Modal\n      </button>\n      <button onClick={hideMoveValidationModal}>Hide Modal</button>\n      <div data-testid=\"is-file1-cut\">{isItemCut('file1').toString()}</div>\n    </div>\n  )\n}\n\nconst renderWithProvider = (\n  ui: React.ReactElement\n): ReturnType<typeof render> => {\n  return render(<ClipboardProvider>{ui}</ClipboardProvider>)\n}\n\ndescribe('ClipboardProvider', () => {\n  beforeEach(() => {\n    jest.clearAllMocks()\n    jest.useFakeTimers()\n  })\n\n  afterEach(() => {\n    jest.useRealTimers()\n  })\n\n  describe('Copy Operations', () => {\n    it('should copy files to clipboard', () => {\n      renderWithProvider(<TestComponent />)\n\n      fireEvent.click(screen.getByText('Copy Files'))\n      // act(() => {\n      //   screen.getByText('Copy Files').click()\n      // })\n\n      expect(screen.getByTestId('clipboard-files-count')).toHaveTextContent('2')\n      expect(screen.getByTestId('clipboard-operation')).toHaveTextContent(\n        'copy'\n      )\n      expect(screen.getByTestId('has-clipboard-data')).toHaveTextContent('true')\n      expect(screen.getByTestId('source-folder-id')).toHaveTextContent(\n        'source-folder'\n      )\n      expect(screen.getByTestId('cut-item-ids')).toHaveTextContent('')\n    })\n\n    it('should handle copy without source folder', () => {\n      const { result } = renderHook(() => useClipboardContext(), {\n        wrapper: ClipboardProvider\n      })\n\n      actHook(() => {\n        result.current.copyFiles([mockFile1])\n      })\n\n      expect(result.current.clipboardData.sourceFolderIds).toBeNull()\n    })\n  })\n\n  describe('Cut Operations', () => {\n    it('should cut files to clipboard', () => {\n      renderWithProvider(<TestComponent />)\n\n      fireEvent.click(screen.getByText('Cut Files'))\n      // act(() => {\n      //   screen.getByText('Cut Files').click()\n      // })\n\n      expect(screen.getByTestId('clipboard-files-count')).toHaveTextContent('1')\n      expect(screen.getByTestId('clipboard-operation')).toHaveTextContent('cut')\n      expect(screen.getByTestId('has-clipboard-data')).toHaveTextContent('true')\n      expect(screen.getByTestId('cut-item-ids')).toHaveTextContent('file1')\n      expect(screen.getByTestId('is-file1-cut')).toHaveTextContent('true')\n    })\n\n    it('should track multiple cut item IDs', () => {\n      const { result } = renderHook(() => useClipboardContext(), {\n        wrapper: ClipboardProvider\n      })\n\n      actHook(() => {\n        result.current.cutFiles([mockFile1, mockFile2])\n      })\n\n      expect(result.current.clipboardData.cutItemIds.has('file1')).toBe(true)\n      expect(result.current.clipboardData.cutItemIds.has('file2')).toBe(true)\n      expect(result.current.isItemCut('file1')).toBe(true)\n      expect(result.current.isItemCut('file2')).toBe(true)\n      expect(result.current.isItemCut('file3')).toBe(false)\n    })\n  })\n\n  describe('Clear Operations', () => {\n    it('should clear clipboard data', () => {\n      renderWithProvider(<TestComponent />)\n\n      // First copy some files\n      fireEvent.click(screen.getByText('Copy Files'))\n      // act(() => {\n      //   screen.getByText('Copy Files').click()\n      // })\n\n      expect(screen.getByTestId('has-clipboard-data')).toHaveTextContent('true')\n\n      // Then clear\n      fireEvent.click(screen.getByText('Clear Clipboard'))\n      // act(() => {\n      //   screen.getByText('Clear Clipboard').click()\n      // })\n\n      expect(screen.getByTestId('clipboard-files-count')).toHaveTextContent('0')\n      expect(screen.getByTestId('clipboard-operation')).toHaveTextContent(\n        'none'\n      )\n      expect(screen.getByTestId('has-clipboard-data')).toHaveTextContent(\n        'false'\n      )\n      expect(screen.getByTestId('cut-item-ids')).toHaveTextContent('')\n      expect(screen.getByTestId('source-folder-id')).toHaveTextContent('none')\n    })\n  })\n\n  describe('Move Validation Modal', () => {\n    it('should show move validation modal', () => {\n      renderWithProvider(<TestComponent />)\n\n      fireEvent.click(screen.getByText('Show Modal'))\n      // act(() => {\n      //   screen.getByText('Show Modal').click()\n      // })\n\n      expect(screen.getByTestId('modal-visible')).toHaveTextContent('true')\n      expect(screen.getByTestId('modal-type')).toHaveTextContent('moveOutside')\n    })\n\n    it('should hide move validation modal', () => {\n      renderWithProvider(<TestComponent />)\n\n      // First show modal\n      fireEvent.click(screen.getByText('Show Modal'))\n      // act(() => {\n      //   screen.getByText('Show Modal').click()\n      // })\n\n      expect(screen.getByTestId('modal-visible')).toHaveTextContent('true')\n\n      // Then hide modal\n      fireEvent.click(screen.getByText('Hide Modal'))\n      // act(() => {\n      //   screen.getByText('Hide Modal').click()\n      // })\n\n      expect(screen.getByTestId('modal-visible')).toHaveTextContent('false')\n      expect(screen.getByTestId('modal-type')).toHaveTextContent('none')\n    })\n\n    it('should handle all modal types', () => {\n      const { result } = renderHook(() => useClipboardContext(), {\n        wrapper: ClipboardProvider\n      })\n\n      const modalTypes = [\n        'moveOutside',\n        'moveInside',\n        'moveSharedInside'\n      ] as const\n\n      modalTypes.forEach(type => {\n        actHook(() => {\n          result.current.showMoveValidationModal(\n            type,\n            mockFile1,\n            mockFolder,\n            async (): Promise<void> => {\n              // Empty async function for test\n            },\n            (): void => {\n              // Empty function for test\n            }\n          )\n        })\n\n        expect(result.current.moveValidationModal.type).toBe(type)\n        expect(result.current.moveValidationModal.isVisible).toBe(true)\n        expect(result.current.moveValidationModal.file).toEqual(mockFile1)\n        expect(result.current.moveValidationModal.targetFolder).toEqual(\n          mockFolder\n        )\n      })\n    })\n  })\n\n  describe('State Transitions', () => {\n    it('should replace clipboard data when copying new files', () => {\n      const { result } = renderHook(() => useClipboardContext(), {\n        wrapper: ClipboardProvider\n      })\n\n      // First copy\n      actHook(() => {\n        result.current.copyFiles([mockFile1])\n      })\n\n      expect(result.current.clipboardData.files).toHaveLength(1)\n      expect(result.current.clipboardData.files[0]._id).toBe('file1')\n\n      // Second copy should replace\n      actHook(() => {\n        result.current.copyFiles([mockFile2])\n      })\n\n      expect(result.current.clipboardData.files).toHaveLength(1)\n      expect(result.current.clipboardData.files[0]._id).toBe('file2')\n    })\n\n    it('should transition from copy to cut operation', () => {\n      const { result } = renderHook(() => useClipboardContext(), {\n        wrapper: ClipboardProvider\n      })\n\n      // First copy\n      actHook(() => {\n        result.current.copyFiles([mockFile1])\n      })\n\n      expect(result.current.clipboardData.operation).toBe('copy')\n      expect(result.current.clipboardData.cutItemIds.size).toBe(0)\n\n      // Then cut\n      actHook(() => {\n        result.current.cutFiles([mockFile2])\n      })\n\n      expect(result.current.clipboardData.operation).toBe('cut')\n      expect(result.current.clipboardData.cutItemIds.has('file2')).toBe(true)\n      expect(result.current.clipboardData.cutItemIds.has('file1')).toBe(false)\n    })\n  })\n})\n"
  },
  {
    "path": "src/contexts/ClipboardProvider.tsx",
    "content": "import React, {\n  createContext,\n  useContext,\n  useReducer,\n  useCallback,\n  ReactNode\n} from 'react'\n\nimport { IOCozyFile } from 'cozy-client/types/types'\n\nexport const OPERATION_CUT = 'cut' as const\nexport const OPERATION_COPY = 'copy' as const\n\ninterface MoveValidationModal {\n  isVisible: boolean\n  type: 'moveOutside' | 'moveInside' | 'moveSharedInside' | null\n  file: IOCozyFile | null\n  targetFolder: IOCozyFile\n  onConfirm: (() => Promise<void>) | null\n  onCancel: (() => void) | null\n}\n\ninterface ClipboardState {\n  files: IOCozyFile[]\n  operation: typeof OPERATION_COPY | typeof OPERATION_CUT | null\n  timestamp: number | null\n  cutItemIds: Set<string>\n  sourceFolderIds: Set<string> | null\n  moveValidationModal: MoveValidationModal\n  sourceDirectory: IOCozyFile\n}\n\ninterface ClipboardContextValue {\n  clipboardData: ClipboardState\n  copyFiles: (files: IOCozyFile[], sourceFolderIds?: Set<string>) => void\n  cutFiles: (\n    files: IOCozyFile[],\n    sourceFolderIds?: Set<string>,\n    sourceDirectory?: IOCozyFile\n  ) => void\n  clearClipboard: () => void\n  hasClipboardData: boolean\n  isItemCut: (itemId: string) => boolean\n  showMoveValidationModal: (\n    type: MoveValidationModal['type'],\n    file: IOCozyFile,\n    targetFolder: IOCozyFile,\n    onConfirm: () => Promise<void>,\n    onCancel: () => void\n  ) => void\n  hideMoveValidationModal: () => void\n  moveValidationModal: MoveValidationModal\n}\n\nconst COPY_FILES = 'COPY_FILES'\nconst CUT_FILES = 'CUT_FILES'\nconst CLEAR_CLIPBOARD = 'CLEAR_CLIPBOARD'\nconst SHOW_SHARING_MODAL = 'SHOW_SHARING_MODAL'\nconst HIDE_SHARING_MODAL = 'HIDE_SHARING_MODAL'\n\ntype ClipboardAction =\n  | {\n      type: typeof COPY_FILES\n      payload: {\n        files: IOCozyFile[]\n        sourceFolderIds?: Set<string>\n        sourceDirectory?: IOCozyFile\n      }\n    }\n  | {\n      type: typeof CUT_FILES\n      payload: {\n        files: IOCozyFile[]\n        sourceFolderIds?: Set<string>\n        sourceDirectory?: IOCozyFile\n      }\n    }\n  | { type: typeof CLEAR_CLIPBOARD }\n  | {\n      type: typeof SHOW_SHARING_MODAL\n      payload: {\n        type: MoveValidationModal['type']\n        file: IOCozyFile\n        targetFolder: IOCozyFile\n        onConfirm: () => Promise<void>\n        onCancel: () => void\n      }\n    }\n  | { type: typeof HIDE_SHARING_MODAL }\n\nconst initialState: ClipboardState = {\n  files: [],\n  operation: null,\n  timestamp: null,\n  cutItemIds: new Set(),\n  sourceFolderIds: new Set(),\n  sourceDirectory: {} as IOCozyFile,\n  moveValidationModal: {\n    isVisible: false,\n    type: null,\n    file: null,\n    targetFolder: {} as IOCozyFile,\n    onConfirm: null,\n    onCancel: null\n  }\n}\n\nconst clipboardReducer = (\n  state: ClipboardState,\n  action: ClipboardAction\n): ClipboardState => {\n  switch (action.type) {\n    case COPY_FILES:\n      return {\n        ...state,\n        files: [...action.payload.files],\n        operation: OPERATION_COPY,\n        timestamp: Date.now(),\n        cutItemIds: new Set(),\n        sourceFolderIds: action.payload.sourceFolderIds ?? null,\n        sourceDirectory: action.payload.sourceDirectory ?? ({} as IOCozyFile)\n      }\n    case CUT_FILES:\n      return {\n        ...state,\n        files: [...action.payload.files],\n        operation: OPERATION_CUT,\n        timestamp: Date.now(),\n        cutItemIds: new Set(action.payload.files.map(file => file._id)),\n        sourceFolderIds: action.payload.sourceFolderIds ?? null,\n        sourceDirectory: action.payload.sourceDirectory ?? ({} as IOCozyFile)\n      }\n    case CLEAR_CLIPBOARD:\n      return {\n        ...initialState\n      }\n    case SHOW_SHARING_MODAL:\n      return {\n        ...state,\n        moveValidationModal: {\n          isVisible: true,\n          type: action.payload.type,\n          file: action.payload.file,\n          targetFolder: action.payload.targetFolder,\n          onConfirm: action.payload.onConfirm,\n          onCancel: action.payload.onCancel\n        }\n      }\n    case HIDE_SHARING_MODAL:\n      return {\n        ...state,\n        moveValidationModal: {\n          ...initialState.moveValidationModal\n        }\n      }\n    default:\n      return state\n  }\n}\n\nconst ClipboardContext = createContext<ClipboardContextValue | undefined>(\n  undefined\n)\n\ninterface ClipboardProviderProps {\n  children: ReactNode\n}\n\nconst ClipboardProvider: React.FC<ClipboardProviderProps> = ({ children }) => {\n  const [state, dispatch] = useReducer(clipboardReducer, initialState)\n\n  const copyFiles = useCallback(\n    (files: IOCozyFile[], sourceFolderIds?: Set<string>) => {\n      dispatch({\n        type: COPY_FILES,\n        payload: { files, sourceFolderIds }\n      })\n    },\n    []\n  )\n\n  const cutFiles = useCallback(\n    (\n      files: IOCozyFile[],\n      sourceFolderIds?: Set<string>,\n      sourceDirectory?: IOCozyFile\n    ) => {\n      dispatch({\n        type: CUT_FILES,\n        payload: { files, sourceFolderIds, sourceDirectory }\n      })\n    },\n    []\n  )\n\n  const clearClipboard = useCallback(() => {\n    dispatch({ type: CLEAR_CLIPBOARD })\n  }, [])\n\n  const showMoveValidationModal = useCallback(\n    (\n      type: MoveValidationModal['type'],\n      file: IOCozyFile,\n      targetFolder: IOCozyFile,\n      onConfirm: () => Promise<void>,\n      onCancel: () => void\n    ) => {\n      dispatch({\n        type: SHOW_SHARING_MODAL,\n        payload: { type, file, targetFolder, onConfirm, onCancel }\n      })\n    },\n    []\n  )\n\n  const hideMoveValidationModal = useCallback(() => {\n    dispatch({ type: HIDE_SHARING_MODAL })\n  }, [])\n\n  const hasClipboardData = state.files.length > 0 && Boolean(state.operation)\n\n  const isItemCut = useCallback(\n    (itemId: string) => {\n      return state.cutItemIds.has(itemId)\n    },\n    [state.cutItemIds]\n  )\n\n  const value: ClipboardContextValue = {\n    clipboardData: state,\n    copyFiles,\n    cutFiles,\n    clearClipboard,\n    hasClipboardData,\n    isItemCut,\n    showMoveValidationModal,\n    hideMoveValidationModal,\n    moveValidationModal: state.moveValidationModal\n  }\n\n  return (\n    <ClipboardContext.Provider value={value}>\n      {children}\n    </ClipboardContext.Provider>\n  )\n}\n\nexport const useClipboardContext = (): ClipboardContextValue => {\n  const context = useContext(ClipboardContext)\n  if (!context) {\n    throw new Error(\n      'useClipboardContext must be used within a ClipboardProvider'\n    )\n  }\n  return context\n}\n\nexport default ClipboardProvider\n"
  },
  {
    "path": "src/declarations.d.ts",
    "content": "declare module 'cozy-ui/*'\n\ndeclare module 'cozy-ui/transpiled/react/styles' {\n  export function makeStyles<T>(styles: T): () => T\n}\n\ndeclare module 'cozy-ui/transpiled/react/Icons/*' {\n  const Icon: React.ComponentType<{\n    className?: string\n    color?: string\n    size?: string\n  }>\n  export default Icon\n}\n\ndeclare module 'cozy-ui/transpiled/react' {\n  export const logger: {\n    info: (message: string, ...rest: unknown[]) => void\n  }\n\n  export const BreakpointsProvider: React.ComponentType\n  export const MuiCozyTheme: React.ComponentType\n}\n\ndeclare module 'twake-i18n' {\n  export const useI18n: () => {\n    t: (key: string, options?: Record<string, unknown>) => string\n    f: (date: Date | number, format: string) => string\n    lang: string\n  }\n}\n\ndeclare module 'cozy-ui/transpiled/react/providers/Alert' {\n  export interface showAlertProps {\n    message: string\n    severity?:\n      | 'primary'\n      | 'secondary'\n      | 'success'\n      | 'error'\n      | 'warning'\n      | 'info'\n    action?: React.ReactNode\n    duration?: number | null\n    noClickAway?: boolean\n  }\n\n  export type showAlertFunction = (props: showAlertProps) => void\n\n  export const useAlert: () => {\n    showAlert: showAlertFunction\n  }\n}\n\ndeclare module 'cozy-ui/transpiled/react/providers/Breakpoints' {\n  const useBreakpoints: () => {\n    isMobile: boolean\n    isTablet: boolean\n    isDesktop: boolean\n  }\n\n  export default useBreakpoints\n}\n\ndeclare module 'models/index' {\n  export const CozyFile: {\n    splitFilename: (file: IOCozyFile) => { filename: string; extension: string }\n  }\n}\n\ndeclare module 'cozy-client/dist/models/file' {\n  export const splitFilename: (file: IOCozyFile) => {\n    filename: string\n    extension: string\n  }\n  export const isFile: (file: IOCozyFile) => boolean\n  export const isDirectory: (\n    file: import('components/FolderPicker/types').File\n  ) => boolean\n  export const isOnlyOfficeFile: (\n    file: import('components/FolderPicker/types').File\n  ) => boolean\n  export const isShortcut: (\n    file: import('components/FolderPicker/types').File\n  ) => boolean\n  export const isNote: (\n    file: import('components/FolderPicker/types').File\n  ) => boolean\n  export const isDocs: (\n    file: import('components/FolderPicker/types').File\n  ) => boolean\n  export const shouldBeOpenedByOnlyOffice: (\n    file: import('components/FolderPicker/types').File\n  ) => boolean\n  export const getFullpath: (\n    client: import('cozy-client/types/CozyClient').CozyClient,\n    dirID: string,\n    filename: string,\n    driveId: string\n  ) => Promise<string>\n}\n\ndeclare module 'cozy-client/dist/models/note' {\n  export const fetchURL: (\n    client: import('cozy-client/types/CozyClient').CozyClient,\n    file: { id: string },\n    options: { pathname: string }\n  ) => Promise<string>\n}\n\ndeclare module 'cozy-client/dist/models/instance' {\n  export const buildPremiumLink: (instanceInfo: InstanceInfo) => string\n}\n\ndeclare module 'cozy-ui-plus/dist/Paywall' {\n  export const AiAssistantPaywall: React.ComponentType<{\n    onClose: () => void\n  }>\n}\n\ndeclare module '*.svg' {\n  import { FC, SVGProps } from 'react'\n  const content: FC<SVGProps<SVGElement>>\n  export default content\n}\n\ndeclare module 'cozy-ui/transpiled/react/ActionsMenu/Actions' {\n  export interface Action<T = import('cozy-client/types/types').IOCozyFile> {\n    name: string\n    label?: string\n    icon: React.ComponentType | string\n    displayInSelectionBar?: boolean\n    displayCondition?: (\n      docs: import('cozy-client/types/types').IOCozyFile[]\n    ) => boolean\n    disabled?: (docs: import('cozy-client/types/types').IOCozyFile[]) => boolean\n    action?: (\n      docs: T[],\n      opts: { handleAction: HandleActionCallback }\n    ) => Promise<void> | void\n    Component: ForwardRefExoticComponent<RefAttributes<React.ComponentType>>\n  }\n\n  export function divider(): Action\n\n  export function makeActions(\n    arg1: (((props?: T) => Action) | boolean)[],\n    T\n  ): Record<string, Action>[]\n}\n\ndeclare module 'cozy-sharing' {\n  export const useSharingContext: () => {\n    allLoaded: boolean\n    refresh: () => void\n  }\n\n  export const useNativeFileSharing: () => {\n    isNativeFileSharingAvailable: boolean\n    shareFilesNative: (\n      files: import('cozy-client/types/CozyClient').CozyClient[]\n    ) => void\n  }\n\n  export const shareNative: (props?: T) => Action\n}\n\ndeclare module 'cozy-ui/transpiled/react/Nav' {\n  export const NavIcon: React.ComponentType<{\n    icon: string | React.ComponentType\n  }>\n  export const NavText: React.ComponentType\n  export const NavItem: React.ComponentType\n  export const NavLink: { className: string; activeClassName: string }\n}\n\ndeclare module 'cozy-ui/transpiled/react/Typography' {\n  const Typography: React.ComponentType<{\n    variant?: string\n    color?: string\n    noWrap?: boolean\n    className?: string\n  }>\n  export default Typography\n}\n\ndeclare module 'cozy-keys-lib' {\n  export const useVaultClient: () => object\n}\n\ndeclare module '*.styl' {\n  const content: Record<string, string>\n  export default content\n}\n\ndeclare module 'cozy-realtime' {\n  export default class CozyRealtime {\n    constructor(options: {\n      client: import('cozy-client').default\n      sharedDriveId?: string\n    })\n    subscribe: (\n      event: string,\n      doctype: string,\n      callback: () => void | Promise<void>\n    ) => void\n    unsubscribe: (\n      event: string,\n      doctype: string,\n      callback: () => void | Promise<void>\n    ) => void\n    stop: () => void\n  }\n}\n\ndeclare module 'cozy-viewer/dist/Panel/AI/AIAssistantPanel' {\n  const AIAssistantPanel: React.ComponentType<{\n    className?: string\n  }>\n  export default AIAssistantPanel\n}\n\ndeclare module 'cozy-viewer/dist/hoc/withViewerLocales' {\n  const withViewerLocales: <P>(\n    Component: React.ComponentType<P>\n  ) => React.ComponentType<P>\n  export { withViewerLocales }\n}\n\ndeclare module 'cozy-viewer/dist/providers/ViewerProvider' {\n  export const useViewer: () => {\n    isOpenAiAssistant: boolean\n  }\n}\n"
  },
  {
    "path": "src/hooks/helpers.d.ts",
    "content": "export declare function changeLocation(url: string): void\n\nexport declare function displayedFolderOrRootFolder(displayedFolder: unknow): {\n  id: string\n}\n\nexport declare function isEditableTarget(target: EventTarget | null): boolean\n\nexport declare function shouldBlockKeyboardShortcuts(\n  target: EventTarget | null\n): boolean\n\nexport declare function normalizeKey(\n  event: KeyboardEvent,\n  isApple: boolean\n): string\n"
  },
  {
    "path": "src/hooks/helpers.js",
    "content": "import { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config'\n\n/**\n * This helper function is used to change the location of the current window\n * This main purpose is to help for testing\n * @param {string} url - The url to change the location to\n */\nexport const changeLocation = url => {\n  window.location = url\n}\n\n/**\n * Returns displayed folder or root folder if no display folder (like in recent or sharing)\n * or if trash folder\n * @param {object} displayedFolder\n * @returns {object}\n */\nexport const displayedFolderOrRootFolder = displayedFolder =>\n  !displayedFolder || displayedFolder._id === TRASH_DIR_ID\n    ? { id: ROOT_DIR_ID }\n    : displayedFolder\n\n/**\n * Check if targeted element can editable\n * @param {EventTarget | null} target\n * @returns {boolean}\n */\nexport const isEditableTarget = target =>\n  target instanceof HTMLInputElement ||\n  target instanceof HTMLTextAreaElement ||\n  (target instanceof HTMLElement && target.isContentEditable)\n\n/**\n * Check if targeted element can editable except checkbox\n * @param {EventTarget | null} target\n * @returns {boolean}\n */\nexport const shouldBlockKeyboardShortcuts = target => {\n  if (!target || !(target instanceof HTMLElement)) return false\n\n  const tag = target.tagName.toLowerCase()\n  const type = target.getAttribute('type')?.toLowerCase()\n\n  if (\n    tag === 'input' &&\n    type !== 'checkbox' &&\n    !target.readOnly &&\n    !target.disabled\n  ) {\n    return true\n  }\n\n  if (tag === 'textarea' && !target.readOnly && !target.disabled) {\n    return true\n  }\n\n  if (target.isContentEditable) {\n    return true\n  }\n\n  return false\n}\n\n/**\n * Normalize shortcut keys\n * @param {KeyboardEvent} event\n * @param {boolean} isApple\n * @returns {string}\n */\nexport const normalizeKey = (event, isApple) => {\n  const keys = []\n\n  if (isApple ? event.metaKey : event.ctrlKey) keys.push('Ctrl')\n\n  const key = event.key.toLowerCase()\n\n  if (key === 'delete' || key === 'del' || (isApple && key === 'backspace')) {\n    keys.push('delete')\n  } else {\n    keys.push(key)\n  }\n\n  return keys.join('+')\n}\n"
  },
  {
    "path": "src/hooks/index.js",
    "content": "export { default as useCurrentFileId } from './useCurrentFileId'\nexport { default as useCurrentFolderId } from './useCurrentFolderId'\nexport { default as useDisplayedFolder } from './useDisplayedFolder'\nexport { default as useParentFolder } from './useParentFolder'\nexport { useRedirectLink } from './useRedirectLink'\nexport { useFolderSort } from './useFolderSort'\nexport { useRecentIcons, addRecentIcon } from './useRecentIcons'\n"
  },
  {
    "path": "src/hooks/useCurrentFileId.jsx",
    "content": "import { useParams } from 'react-router-dom'\n\nconst useCurrentFileId = () => {\n  const { fileId } = useParams()\n\n  if (fileId) {\n    return fileId\n  }\n  return null\n}\n\nexport default useCurrentFileId\n"
  },
  {
    "path": "src/hooks/useCurrentFileId.spec.jsx",
    "content": "import ReactRouter from 'react-router-dom'\n\nimport useCurrentFileId from './useCurrentFileId'\n\njest.mock('react-router-dom', () => ({\n  ...jest.requireActual('react-router-dom'),\n  useParams: jest.fn()\n}))\n\ndescribe('useCurrentFileId', () => {\n  it('should return file id if in params', () => {\n    jest.spyOn(ReactRouter, 'useParams').mockReturnValue({ fileId: 'file-id' })\n\n    const currentFileId = useCurrentFileId()\n\n    expect(currentFileId).toBe('file-id')\n  })\n\n  it('should return null id if not in params', () => {\n    jest.spyOn(ReactRouter, 'useParams').mockReturnValue({})\n\n    const currentFileId = useCurrentFileId()\n\n    expect(currentFileId).toBe(null)\n  })\n})\n"
  },
  {
    "path": "src/hooks/useCurrentFolderId.jsx",
    "content": "import { useParams, useLocation } from 'react-router-dom'\n\nimport { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config'\n\nconst useCurrentFolderId = () => {\n  const { folderId } = useParams()\n  const { pathname = '' } = useLocation()\n\n  if (folderId) {\n    return folderId\n  } else if (pathname.startsWith('/folder/io.cozy.files.shared-drives-dir')) {\n    return 'io.cozy.files.shared-drives-dir'\n  } else if (pathname === '/folder') {\n    return ROOT_DIR_ID\n  } else if (pathname === '/trash') {\n    return TRASH_DIR_ID\n  }\n  return null\n}\n\nexport default useCurrentFolderId\n"
  },
  {
    "path": "src/hooks/useCurrentFolderId.spec.jsx",
    "content": "import ReactRouter from 'react-router-dom'\n\nimport useCurrentFolderId from './useCurrentFolderId'\n\nimport { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config'\n\njest.mock('react-router-dom', () => ({\n  ...jest.requireActual('react-router-dom'),\n  useParams: jest.fn(),\n  useLocation: jest.fn()\n}))\n\ndescribe('useCurrentFolderId', () => {\n  it('should return file id if in params', () => {\n    jest\n      .spyOn(ReactRouter, 'useParams')\n      .mockReturnValue({ folderId: 'folder-id' })\n    jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({})\n\n    const currentFolderId = useCurrentFolderId()\n\n    expect(currentFolderId).toBe('folder-id')\n  })\n\n  it('should return ROOT_DIR_ID if in /folder', () => {\n    jest.spyOn(ReactRouter, 'useParams').mockReturnValue({})\n    jest\n      .spyOn(ReactRouter, 'useLocation')\n      .mockReturnValue({ pathname: '/folder' })\n\n    const currentFolderId = useCurrentFolderId()\n\n    expect(currentFolderId).toBe(ROOT_DIR_ID)\n  })\n\n  it('should return TRASH_DIR_ID if in /trash', () => {\n    jest.spyOn(ReactRouter, 'useParams').mockReturnValue({})\n    jest\n      .spyOn(ReactRouter, 'useLocation')\n      .mockReturnValue({ pathname: '/trash' })\n\n    const currentFolderId = useCurrentFolderId()\n\n    expect(currentFolderId).toBe(TRASH_DIR_ID)\n  })\n\n  it('should return io.cozy.files.shared-drives-dir if in /folder/io.cozy.files.shared-drives-dir', () => {\n    jest.spyOn(ReactRouter, 'useParams').mockReturnValue({})\n    jest\n      .spyOn(ReactRouter, 'useLocation')\n      .mockReturnValue({ pathname: '/folder/io.cozy.files.shared-drives-dir' })\n\n    const currentFolderId = useCurrentFolderId()\n\n    expect(currentFolderId).toBe('io.cozy.files.shared-drives-dir')\n  })\n\n  it('should return null', () => {\n    jest.spyOn(ReactRouter, 'useParams').mockReturnValue({})\n    jest.spyOn(ReactRouter, 'useLocation').mockReturnValue({})\n\n    const currentFolderId = useCurrentFolderId()\n\n    expect(currentFolderId).toBe(null)\n  })\n})\n"
  },
  {
    "path": "src/hooks/useDebounce.jsx",
    "content": "import { useEffect, useState } from 'react'\n\nconst useDebounce = (value, { delay, ignore }) => {\n  const [debouncedValue, setDebouncedValue] = useState(value)\n\n  useEffect(() => {\n    // eslint-disable-next-line react-hooks/set-state-in-effect\n    if (ignore) return setDebouncedValue(value)\n\n    const handler = setTimeout(() => {\n      setDebouncedValue(value)\n    }, delay)\n\n    return () => {\n      clearTimeout(handler)\n    }\n  }, [value, delay, ignore])\n\n  return debouncedValue\n}\n\nexport default useDebounce\n"
  },
  {
    "path": "src/hooks/useDisplayedFolder.spec.jsx",
    "content": "import { useQuery } from 'cozy-client'\n\nimport useCurrentFolderId from './useCurrentFolderId'\nimport useDisplayedFolder from './useDisplayedFolder'\n\nimport { ROOT_DIR_ID } from '@/constants/config'\n\njest.mock('cozy-client', () => ({\n  ...jest.requireActual('cozy-client'),\n  useQuery: jest.fn()\n}))\n\njest.mock('./useCurrentFolderId')\n\ndescribe('useDisplayedFolder', () => {\n  it('should return file folder if current folder exists', () => {\n    const FOLDER = {\n      id: 'folder-id',\n      name: 'Folder name'\n    }\n    useQuery.mockReturnValue({ data: FOLDER })\n    useCurrentFolderId.mockReturnValue(FOLDER.id)\n\n    const { displayedFolder } = useDisplayedFolder()\n\n    expect(displayedFolder).toBe(FOLDER)\n  })\n\n  it(\"should return root dir if current folder isn't found\", () => {\n    const FOLDER = {\n      id: ROOT_DIR_ID,\n      name: 'Root'\n    }\n\n    useQuery.mockReturnValue({ data: FOLDER })\n    useCurrentFolderId.mockReturnValue(null)\n\n    const { displayedFolder } = useDisplayedFolder()\n\n    expect(displayedFolder).toBe(FOLDER)\n  })\n})\n"
  },
  {
    "path": "src/hooks/useDisplayedFolder.tsx",
    "content": "import { useQuery } from 'cozy-client'\nimport { IOCozyFile } from 'cozy-client/types/types'\n\nimport { ROOT_DIR_ID } from '@/constants/config'\nimport useCurrentFolderId from '@/hooks/useCurrentFolderId'\nimport { buildFileOrFolderByIdQuery } from '@/queries'\n\ninterface DisplayedFolderResult {\n  isNotFound: boolean\n  displayedFolder: IOCozyFile | null\n  initialDirId: string | null\n}\n\nconst useDisplayedFolder = (): DisplayedFolderResult => {\n  const folderId = useCurrentFolderId() ?? ROOT_DIR_ID\n\n  const folderQuery = buildFileOrFolderByIdQuery(folderId)\n  const folderResult = useQuery(\n    folderQuery.definition,\n    folderQuery.options\n  ) as unknown as {\n    data?: IOCozyFile | null\n    fetchStatus: string\n    lastError: { status: number }\n  }\n\n  const displayedFolder = folderResult.data ?? null\n  const initialDirId = displayedFolder?.id ?? null\n\n  if (folderId) {\n    const isNotFound =\n      folderResult.fetchStatus === 'failed' &&\n      folderResult.lastError.status === 404\n\n    return {\n      isNotFound,\n      displayedFolder,\n      initialDirId\n    }\n  }\n\n  return {\n    isNotFound: true,\n    displayedFolder: null,\n    initialDirId: null\n  }\n}\n\nexport default useDisplayedFolder\n"
  },
  {
    "path": "src/hooks/useFolderSort/index.spec.jsx",
    "content": "import { renderHook, act, waitFor } from '@testing-library/react'\n\nimport { useClient } from 'cozy-client'\nimport flag from 'cozy-flags'\n\nimport { useFolderSort } from './index'\n\nimport { DEFAULT_SORT, SORT_BY_UPDATE_DATE } from '@/config/sort'\nimport { TRASH_DIR_ID } from '@/constants/config'\nimport { DOCTYPE_DRIVE_SETTINGS } from '@/lib/doctypes'\nimport logger from '@/lib/logger'\nimport { usePublicContext } from '@/modules/public/PublicProvider'\n\njest.mock('cozy-client', () => ({\n  useClient: jest.fn(),\n  Q: jest.fn().mockReturnValue('mocked-query'),\n  useQuery: jest.fn()\n}))\n\njest.mock('cozy-flags', () => jest.fn())\n\njest.mock('@/lib/logger', () => ({\n  warn: jest.fn(),\n  info: jest.fn(),\n  error: jest.fn()\n}))\n\njest.mock('@/modules/public/PublicProvider', () => ({\n  usePublicContext: jest.fn()\n}))\n\nconst mockUseClient = useClient\nconst mockFlag = flag\nconst mockUsePublicContext = usePublicContext\n\ndescribe('useFolderSort', () => {\n  let mockClient\n  let consoleErrorSpy\n\n  beforeEach(() => {\n    jest.clearAllMocks()\n\n    consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})\n\n    mockClient = {\n      save: jest.fn().mockResolvedValue({}),\n      query: jest.fn().mockResolvedValue({ data: [] })\n    }\n    mockUseClient.mockReturnValue(mockClient)\n\n    mockFlag.mockImplementation(flagName => {\n      if (flagName === 'drive.save-sort-choice.enabled') {\n        return true\n      }\n      return false\n    })\n\n    mockUsePublicContext.mockReturnValue({\n      isPublic: false\n    })\n  })\n\n  afterEach(() => {\n    consoleErrorSpy.mockRestore()\n  })\n\n  describe('default sort behavior', () => {\n    it('should use DEFAULT_SORT for regular folders', () => {\n      const folderId = 'regular-folder-id'\n      mockClient.query.mockResolvedValue({\n        data: []\n      })\n\n      const { result } = renderHook(() => useFolderSort(folderId))\n\n      const [currentSort] = result.current\n      expect(currentSort).toEqual(DEFAULT_SORT)\n    })\n\n    it('should use SORT_BY_UPDATE_DATE for trash folder', () => {\n      const folderId = TRASH_DIR_ID\n      mockClient.query.mockResolvedValue({\n        data: []\n      })\n\n      const { result } = renderHook(() => useFolderSort(folderId))\n\n      const [currentSort] = result.current\n      expect(currentSort).toEqual(SORT_BY_UPDATE_DATE)\n    })\n\n    it('should use SORT_BY_UPDATE_DATE for recent folder', () => {\n      const folderId = 'recent'\n      mockClient.query.mockResolvedValue({\n        data: []\n      })\n\n      const { result } = renderHook(() => useFolderSort(folderId))\n\n      const [currentSort] = result.current\n      expect(currentSort).toEqual(SORT_BY_UPDATE_DATE)\n    })\n  })\n\n  describe('loading existing sorting settings', () => {\n    it('should load and apply existing sorting settings when available', async () => {\n      const folderId = 'test-folder'\n      const existingSettings = {\n        _id: 'settings-id',\n        _type: DOCTYPE_DRIVE_SETTINGS,\n        attributes: {\n          attribute: 'updated_at',\n          order: 'desc'\n        }\n      }\n\n      mockClient.query.mockResolvedValue({\n        data: [existingSettings]\n      })\n\n      const { result } = renderHook(() => useFolderSort(folderId))\n\n      await waitFor(() => expect(result.current[2]).toBe(true))\n\n      await waitFor(() =>\n        expect(result.current[0]).toEqual({\n          attribute: 'updated_at',\n          order: 'desc'\n        })\n      )\n\n      const [currentSort] = result.current\n      expect(currentSort).toEqual({\n        attribute: 'updated_at',\n        order: 'desc'\n      })\n    })\n\n    it('should use default values when settings exist but attributes are missing', () => {\n      const folderId = 'test-folder'\n      const existingSettings = {\n        _id: 'settings-id',\n        _type: DOCTYPE_DRIVE_SETTINGS\n      }\n\n      mockClient.query.mockResolvedValue({\n        data: [existingSettings]\n      })\n\n      const { result } = renderHook(() => useFolderSort(folderId))\n\n      const [currentSort] = result.current\n      expect(currentSort).toEqual(DEFAULT_SORT)\n    })\n\n    it('should return consistent sort values on multiple renders', async () => {\n      const folderId = 'test-folder'\n      const existingSettings = {\n        _id: 'settings-id',\n        _type: DOCTYPE_DRIVE_SETTINGS,\n        attributes: {\n          attribute: 'name',\n          order: 'asc'\n        }\n      }\n\n      mockClient.query.mockResolvedValue({\n        data: [existingSettings]\n      })\n\n      const { result, rerender } = renderHook(() => useFolderSort(folderId))\n\n      await waitFor(() => expect(result.current[2]).toBe(true))\n\n      await waitFor(() =>\n        expect(result.current[0]).toEqual({ attribute: 'name', order: 'asc' })\n      )\n\n      const [firstSort] = result.current\n      expect(firstSort).toEqual({\n        attribute: 'name',\n        order: 'asc'\n      })\n\n      rerender()\n\n      const [secondSort] = result.current\n      expect(secondSort).toEqual({\n        attribute: 'name',\n        order: 'asc'\n      })\n    })\n  })\n\n  describe('persisting settings', () => {\n    it('should persist new sorting settings when no existing settings', async () => {\n      const folderId = 'test-folder'\n      const newSort = { attribute: 'updated_at', order: 'desc' }\n\n      mockClient.query.mockResolvedValue({\n        data: []\n      })\n\n      const { result } = renderHook(() => useFolderSort(folderId))\n\n      const [, setSortOrder] = result.current\n      await act(async () => {\n        await setSortOrder(newSort)\n      })\n\n      expect(mockClient.save).toHaveBeenCalledWith({\n        _type: DOCTYPE_DRIVE_SETTINGS,\n        attributes: {\n          ...DEFAULT_SORT,\n          attribute: 'updated_at',\n          order: 'desc'\n        }\n      })\n\n      expect(logger.info).toHaveBeenCalledWith(\n        'Sort settings persisted',\n        newSort\n      )\n    })\n\n    it('should update existing sorting settings', async () => {\n      const folderId = 'test-folder'\n      const existingSettings = {\n        _id: 'settings-id',\n        _type: DOCTYPE_DRIVE_SETTINGS,\n        attributes: {\n          attribute: 'name',\n          order: 'asc'\n        }\n      }\n      const newSort = { attribute: 'updated_at', order: 'desc' }\n\n      mockClient.query.mockResolvedValue({\n        data: [existingSettings]\n      })\n\n      const { result } = renderHook(() => useFolderSort(folderId))\n\n      // Wait for the settings to load\n      await waitFor(() => expect(result.current[2]).toBe(true))\n\n      await waitFor(() =>\n        expect(result.current[0]).toEqual({ attribute: 'name', order: 'asc' })\n      )\n\n      const [, setSortOrder] = result.current\n      await act(async () => {\n        await setSortOrder(newSort)\n      })\n\n      expect(mockClient.save).toHaveBeenCalledWith({\n        ...existingSettings,\n        attributes: {\n          attribute: 'updated_at',\n          order: 'desc'\n        }\n      })\n\n      expect(logger.info).toHaveBeenCalledWith(\n        'Sort settings persisted',\n        newSort\n      )\n    })\n\n    it('should handle save errors gracefully', async () => {\n      const folderId = 'test-folder'\n      const newSort = { attribute: 'updated_at', order: 'desc' }\n      const saveError = new Error('Save failed')\n\n      mockClient.save.mockRejectedValue(saveError)\n      mockClient.query.mockResolvedValue({\n        data: []\n      })\n\n      const { result } = renderHook(() => useFolderSort(folderId))\n\n      const [, setSortOrder] = result.current\n      await act(async () => {\n        await setSortOrder(newSort)\n      })\n\n      expect(logger.error).toHaveBeenCalledWith(\n        'Failed to save sorting preference:',\n        saveError\n      )\n    })\n  })\n\n  describe('public context behavior', () => {\n    it('should not load settings in public view', async () => {\n      const folderId = 'test-folder'\n      const existingSettings = {\n        _id: 'settings-id',\n        _type: DOCTYPE_DRIVE_SETTINGS,\n        attributes: {\n          attribute: 'updated_at',\n          order: 'desc'\n        }\n      }\n\n      mockUsePublicContext.mockReturnValue({\n        isPublic: true\n      })\n\n      mockClient.query.mockResolvedValue({\n        data: [existingSettings]\n      })\n\n      const { result } = renderHook(() => useFolderSort(folderId))\n\n      const [currentSort] = result.current\n\n      expect(currentSort).toEqual(DEFAULT_SORT)\n\n      expect(mockClient.query).not.toHaveBeenCalled()\n    })\n\n    it('should not persist settings in public view', async () => {\n      const folderId = 'test-folder'\n      const newSort = { attribute: 'updated_at', order: 'desc' }\n\n      mockUsePublicContext.mockReturnValue({\n        isPublic: true\n      })\n\n      const { result } = renderHook(() => useFolderSort(folderId))\n\n      const [, setSortOrder] = result.current\n      await act(async () => {\n        await setSortOrder(newSort)\n      })\n\n      expect(logger.warn).toHaveBeenCalledWith(\n        'Cannot persist sort: in public view'\n      )\n\n      expect(mockClient.save).not.toHaveBeenCalled()\n\n      expect(mockClient.query).not.toHaveBeenCalled()\n    })\n  })\n})\n"
  },
  {
    "path": "src/hooks/useFolderSort/index.ts",
    "content": "import { useCallback, useEffect, useState } from 'react'\n\nimport { useClient, Q } from 'cozy-client'\nimport flag from 'cozy-flags'\n\nimport { DEFAULT_SORT, SORT_BY_UPDATE_DATE } from '@/config/sort'\nimport { RECENT_FOLDER_ID, TRASH_DIR_ID } from '@/constants/config'\nimport { DOCTYPE_DRIVE_SETTINGS } from '@/lib/doctypes'\nimport logger from '@/lib/logger'\nimport { usePublicContext } from '@/modules/public/PublicProvider'\n\nexport interface Sort {\n  attribute: string\n  order: string\n}\n\ninterface DriveSettings {\n  _type?: string\n  attributes: Sort\n}\n\ninterface QueryResult {\n  data?: DriveSettings[]\n  fetchStatus?: string\n}\n\nconst useFolderSort = (\n  folderId: string\n): [Sort, (props: Sort) => void, boolean] => {\n  const defaultSort: Sort =\n    folderId === TRASH_DIR_ID || folderId === RECENT_FOLDER_ID\n      ? SORT_BY_UPDATE_DATE\n      : DEFAULT_SORT\n\n  const client = useClient()\n  const { isPublic } = usePublicContext()\n  const [isSettingsLoaded, setIsSettingsLoaded] = useState(false)\n  const [currentSort, setCurrentSort] = useState<Sort>(defaultSort)\n  const [isSaving, setIsSaving] = useState<boolean>(false)\n\n  useEffect(() => {\n    const load = async (): Promise<void> => {\n      if (!client || !flag('drive.save-sort-choice.enabled') || isPublic) {\n        setIsSettingsLoaded(true)\n        return\n      }\n\n      try {\n        const { data } = (await client.query(\n          Q(DOCTYPE_DRIVE_SETTINGS)\n        )) as QueryResult\n\n        if (!data?.length) return\n\n        setCurrentSort(data[0]?.attributes)\n      } catch (error) {\n        logger.error('Failed to load settings:', error)\n      } finally {\n        setIsSettingsLoaded(true)\n      }\n    }\n\n    void load()\n  }, [client, isPublic])\n\n  const setSortOrder = useCallback(\n    async ({ attribute, order }: Sort) => {\n      setCurrentSort({ attribute, order })\n\n      if (!flag('drive.save-sort-choice.enabled')) {\n        logger.warn(\n          'Cannot persist sort: flag drive.save-sort-choice.enabled is not enabled'\n        )\n        return\n      }\n\n      if (!client) {\n        logger.warn('Cannot persist sort: client unavailable')\n        return\n      }\n\n      if (isPublic) {\n        logger.warn('Cannot persist sort: in public view')\n        return\n      }\n\n      if (isSaving) {\n        logger.warn('Cannot persist sort: already saving')\n        return\n      }\n\n      setIsSaving(true)\n\n      try {\n        const { data } = (await client.query(\n          Q(DOCTYPE_DRIVE_SETTINGS)\n        )) as QueryResult\n\n        const settingsToSave: DriveSettings = data?.length\n          ? {\n              ...data[0],\n              attributes: { attribute, order }\n            }\n          : {\n              _type: DOCTYPE_DRIVE_SETTINGS,\n              attributes: { attribute, order }\n            }\n\n        await client.save(settingsToSave)\n        logger.info('Sort settings persisted', { attribute, order })\n      } catch (error) {\n        logger.error('Failed to save sorting preference:', error)\n      } finally {\n        setIsSaving(false)\n      }\n    },\n    [client, isSaving, isPublic, setIsSaving]\n  )\n\n  return [currentSort, setSortOrder, isSettingsLoaded]\n}\n\nexport { useFolderSort }\n"
  },
  {
    "path": "src/hooks/useKeyboardShortcuts.spec.jsx",
    "content": "import '@testing-library/jest-dom'\nimport { renderHook, act } from '@testing-library/react'\nimport React from 'react'\nimport { Provider } from 'react-redux'\nimport { createStore } from 'redux'\n\njest.mock('cozy-client/dist/models/file', () => ({\n  isFile: jest.fn()\n}))\n\njest.mock('cozy-ui/transpiled/react/providers/Alert', () => ({\n  useAlert: jest.fn()\n}))\n\njest.mock('twake-i18n', () => ({\n  useI18n: jest.fn(),\n  translate: jest.fn(key => key),\n  createUseI18n: jest.fn(() => () => ({ t: key => key })),\n  I18nProvider: ({ children }) => children,\n  withOnlyLocales: jest.fn(() => Component => Component),\n  withLocales: jest.fn(() => Component => Component),\n  useExtendI18n: jest.fn()\n}))\n\njest.mock('./helpers', () => ({\n  isEditableTarget: jest.fn(),\n  shouldBlockKeyboardShortcuts: jest.fn(),\n  normalizeKey: jest.fn()\n}))\n\njest.mock('@/components/pushClient', () => ({\n  isMacOS: jest.fn()\n}))\n\njest.mock('@/contexts/ClipboardProvider', () => ({\n  useClipboardContext: jest.fn()\n}))\n\njest.mock('@/hooks', () => ({\n  useDisplayedFolder: jest.fn()\n}))\n\njest.mock('@/modules/drive/rename', () => ({\n  startRenamingAsync: jest.fn()\n}))\n\njest.mock('@/modules/nextcloud/hooks/useNextcloudCurrentFolder', () => ({\n  useNextcloudCurrentFolder: jest.fn()\n}))\n\njest.mock('@/modules/paste', () => ({\n  handlePasteOperation: jest.fn()\n}))\n\njest.mock('@/modules/selection/SelectionProvider', () => ({\n  useSelectionContext: jest.fn()\n}))\n\njest.mock('cozy-flags', () => jest.fn())\n\njest.mock('cozy-sharing', () => ({\n  SharedDocument: ({ children }) =>\n    children({ isSharedByMe: false, link: null, recipients: [] }),\n  SharedRecipientsList: () => null,\n  withLocales: component => component\n}))\n\njest.mock('@/modules/upload/NewItemHighlightProvider', () => ({\n  useNewItemHighlightContext: jest.fn(() => ({ addItems: jest.fn() }))\n}))\n\nimport { isFile } from 'cozy-client/dist/models/file'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { shouldBlockKeyboardShortcuts, normalizeKey } from './helpers'\nimport { useKeyboardShortcuts } from './useKeyboardShortcuts.tsx'\n\nimport { isMacOS } from '@/components/pushClient'\nimport {\n  OPERATION_COPY,\n  OPERATION_CUT,\n  useClipboardContext\n} from '@/contexts/ClipboardProvider'\nimport { useDisplayedFolder } from '@/hooks'\nimport { startRenamingAsync } from '@/modules/drive/rename'\nimport { useNextcloudCurrentFolder } from '@/modules/nextcloud/hooks/useNextcloudCurrentFolder'\nimport { handlePasteOperation } from '@/modules/paste'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\n\ndescribe('useKeyboardShortcuts', () => {\n  let mockDispatch\n  let mockShowAlert\n  let mockT\n  let mockCopyFiles\n  let mockCutFiles\n  let mockClearClipboard\n  let mockSelectAll\n  let mockClearSelection\n  let mockHideSelectionBar\n  let mockShowMoveValidationModal\n  let mockOnPaste\n  let mockClient\n  let mockCurrentFolder\n  let mockSelectedItems\n  let mockItems\n  let store\n\n  const createWrapper = () => {\n    const mockReducer = (state = {}) => state\n    store = createStore(mockReducer)\n\n    return ({ children }) => <Provider store={store}>{children}</Provider>\n  }\n\n  beforeEach(() => {\n    mockT = jest.fn((key, options) => {\n      if (options && options.count !== undefined) {\n        return `${key}_${options.count}`\n      }\n      return key\n    })\n    mockCopyFiles = jest.fn()\n    mockCutFiles = jest.fn()\n    mockClearClipboard = jest.fn()\n    mockSelectAll = jest.fn()\n    mockClearSelection = jest.fn()\n    mockHideSelectionBar = jest.fn()\n    mockShowMoveValidationModal = jest.fn()\n    mockOnPaste = jest.fn()\n    mockDispatch = jest.fn()\n    mockShowAlert = jest.fn()\n\n    mockClient = {\n      save: jest.fn(),\n      query: jest.fn(),\n      collection: jest.fn()\n    }\n\n    mockCurrentFolder = {\n      _id: 'current-folder-id',\n      name: 'Current Folder'\n    }\n\n    mockSelectedItems = [\n      {\n        _id: 'file1',\n        name: 'test1.txt',\n        type: 'file',\n        dir_id: 'parent-folder-1'\n      },\n      {\n        _id: 'file2',\n        name: 'test2.txt',\n        type: 'file',\n        dir_id: 'parent-folder-2'\n      }\n    ]\n\n    mockItems = [\n      { _id: 'file1', name: 'test1.txt', type: 'file' },\n      { _id: 'file2', name: 'test2.txt', type: 'file' },\n      { _id: 'folder1', name: 'Test Folder', type: 'directory' }\n    ]\n\n    jest\n      .spyOn(require('react-redux'), 'useDispatch')\n      .mockReturnValue(mockDispatch)\n\n    useAlert.mockReturnValue({ showAlert: mockShowAlert })\n    useI18n.mockReturnValue({ t: mockT })\n    useClipboardContext.mockReturnValue({\n      clipboardData: {\n        files: [{ _id: 'clipboard-file', name: 'clipboard.txt' }],\n        operation: OPERATION_COPY,\n        sourceFolderIds: new Set(['source-folder-id'])\n      },\n      copyFiles: mockCopyFiles,\n      cutFiles: mockCutFiles,\n      clearClipboard: mockClearClipboard,\n      hasClipboardData: true,\n      showMoveValidationModal: mockShowMoveValidationModal\n    })\n    useSelectionContext.mockReturnValue({\n      selectedItems: mockSelectedItems,\n      selectAll: mockSelectAll,\n      hideSelectionBar: mockHideSelectionBar,\n      clearSelection: mockClearSelection,\n      isSelectAll: false\n    })\n    useDisplayedFolder.mockReturnValue({ displayedFolder: mockCurrentFolder })\n    useNextcloudCurrentFolder.mockReturnValue(mockCurrentFolder)\n\n    isFile.mockReturnValue(true)\n    shouldBlockKeyboardShortcuts.mockReturnValue(false)\n    normalizeKey.mockImplementation((event, isApple) => {\n      const key = event.key.toLowerCase()\n      const ctrl = isApple ? event.metaKey : event.ctrlKey\n      if (ctrl && key === 'c') return 'Ctrl+c'\n      if (ctrl && key === 'x') return 'Ctrl+x'\n      if (ctrl && key === 'v') return 'Ctrl+v'\n      if (ctrl && key === 'a') return 'Ctrl+a'\n      if (key === 'f2') return 'f2'\n      if (key === 'escape') return 'escape'\n      if (key === 'delete') return 'delete'\n      return key\n    })\n    isMacOS.mockReturnValue(false)\n    handlePasteOperation.mockResolvedValue([\n      { success: true, file: { _id: 'pasted-file' }, operation: OPERATION_COPY }\n    ])\n\n    mockCopyFiles.mockClear()\n    mockCutFiles.mockClear()\n    mockClearClipboard.mockClear()\n    mockSelectAll.mockClear()\n    mockClearSelection.mockClear()\n    mockHideSelectionBar.mockClear()\n    mockShowMoveValidationModal.mockClear()\n    mockOnPaste.mockClear()\n    mockDispatch.mockClear()\n    mockShowAlert.mockClear()\n    handlePasteOperation.mockClear()\n  })\n\n  describe('Copy Operations (Ctrl+C / Cmd+C)', () => {\n    it('should copy selected files when Ctrl+C is pressed', () => {\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems,\n            allowCopy: true\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'c',\n        ctrlKey: true,\n        bubbles: true\n      })\n\n      act(() => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockCopyFiles).toHaveBeenCalledWith(\n        mockSelectedItems,\n        new Set(['parent-folder-1', 'parent-folder-2'])\n      )\n      expect(mockShowAlert).toHaveBeenCalledWith({\n        message: 'alert.items_copied_2',\n        severity: 'success'\n      })\n      expect(mockClearSelection).toHaveBeenCalled()\n    })\n\n    it('should show alert when copy is not allowed', () => {\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems,\n            allowCopy: false\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'c',\n        ctrlKey: true,\n        bubbles: true\n      })\n\n      act(() => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockCopyFiles).not.toHaveBeenCalled()\n      expect(mockShowAlert).toHaveBeenCalledWith({\n        message: 'alert.copy_not_allowed',\n        severity: 'secondary'\n      })\n    })\n\n    it('should filter only files for copying', () => {\n      isFile.mockImplementation(item => item.type === 'file')\n\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems,\n            allowCopy: true\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'c',\n        ctrlKey: true,\n        bubbles: true\n      })\n\n      act(() => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockCopyFiles).toHaveBeenCalledWith(\n        mockSelectedItems.filter(item => item.type === 'file'),\n        new Set(['parent-folder-1', 'parent-folder-2'])\n      )\n    })\n  })\n\n  describe('Cut Operations (Ctrl+X / Cmd+X)', () => {\n    it('should cut selected items when Ctrl+X is pressed', () => {\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'x',\n        ctrlKey: true,\n        bubbles: true\n      })\n\n      act(() => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockCutFiles).toHaveBeenCalledWith(\n        mockSelectedItems,\n        new Set(['parent-folder-1', 'parent-folder-2']),\n        mockCurrentFolder\n      )\n      expect(mockShowAlert).toHaveBeenCalledWith({\n        message: 'alert.items_cut_2',\n        severity: 'success'\n      })\n      expect(mockClearSelection).toHaveBeenCalled()\n    })\n  })\n\n  describe('Paste Operations (Ctrl+V / Cmd+V)', () => {\n    it('should paste files when Ctrl+V is pressed', async () => {\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems,\n            canPaste: true,\n            onPaste: mockOnPaste\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'v',\n        ctrlKey: true,\n        bubbles: true\n      })\n\n      await act(async () => {\n        document.dispatchEvent(event)\n      })\n\n      expect(handlePasteOperation).toHaveBeenCalledWith(\n        mockClient,\n        [{ _id: 'clipboard-file', name: 'clipboard.txt' }],\n        OPERATION_COPY,\n        undefined,\n        mockCurrentFolder,\n        {\n          sharingContext: null,\n          showAlert: mockShowAlert,\n          showMoveValidationModal: mockShowMoveValidationModal,\n          t: mockT,\n          isPublic: false\n        }\n      )\n      expect(mockShowAlert).toHaveBeenCalledWith({\n        message: 'alert.item_pasted',\n        severity: 'success'\n      })\n      expect(mockOnPaste).toHaveBeenCalled()\n    })\n\n    it('should clear clipboard after cut operation', async () => {\n      useClipboardContext.mockReturnValue({\n        clipboardData: {\n          files: [{ _id: 'clipboard-file', name: 'clipboard.txt' }],\n          operation: OPERATION_CUT,\n          sourceFolderIds: new Set(['source-folder-id'])\n        },\n        copyFiles: mockCopyFiles,\n        cutFiles: mockCutFiles,\n        clearClipboard: mockClearClipboard,\n        hasClipboardData: true,\n        showMoveValidationModal: mockShowMoveValidationModal\n      })\n\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems,\n            canPaste: true\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'v',\n        ctrlKey: true,\n        bubbles: true\n      })\n\n      await act(async () => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockClearClipboard).toHaveBeenCalled()\n    })\n\n    it('should skip paste when cutting and pasting in same folder', async () => {\n      useClipboardContext.mockReturnValue({\n        clipboardData: {\n          files: [\n            {\n              _id: 'clipboard-file',\n              name: 'clipboard.txt'\n            }\n          ],\n          operation: OPERATION_CUT,\n          sourceFolderIds: new Set(['current-folder-id'])\n        },\n        copyFiles: mockCopyFiles,\n        cutFiles: mockCutFiles,\n        clearClipboard: mockClearClipboard,\n        hasClipboardData: true,\n        showMoveValidationModal: mockShowMoveValidationModal\n      })\n\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems,\n            canPaste: true\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'v',\n        ctrlKey: true,\n        bubbles: true\n      })\n\n      await act(async () => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockShowAlert).toHaveBeenCalledWith({\n        message: 'alert.paste_same_folder_skipped',\n        severity: 'secondary'\n      })\n      expect(handlePasteOperation).not.toHaveBeenCalled()\n    })\n  })\n\n  describe('Move with Validation Modals', () => {\n    it('should call showMoveValidationModal during paste operation', async () => {\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems,\n            canPaste: true,\n            sharingContext: { isShared: true }\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'v',\n        ctrlKey: true,\n        bubbles: true\n      })\n\n      await act(async () => {\n        document.dispatchEvent(event)\n      })\n\n      expect(handlePasteOperation).toHaveBeenCalledWith(\n        mockClient,\n        expect.any(Array),\n        OPERATION_COPY,\n        undefined,\n        mockCurrentFolder,\n        expect.objectContaining({\n          sharingContext: { isShared: true },\n          showMoveValidationModal: mockShowMoveValidationModal\n        })\n      )\n    })\n  })\n\n  describe('Select All (Ctrl+A / Cmd+A)', () => {\n    it('should select all items when Ctrl+A is pressed', () => {\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'a',\n        ctrlKey: true,\n        bubbles: true\n      })\n\n      act(() => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockSelectAll).toHaveBeenCalledWith(mockItems)\n    })\n\n    it('should clear selection when all items are already selected', () => {\n      useSelectionContext.mockReturnValue({\n        selectedItems: mockSelectedItems,\n        selectAll: mockSelectAll,\n        hideSelectionBar: mockHideSelectionBar,\n        clearSelection: mockClearSelection,\n        isSelectAll: true\n      })\n\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'a',\n        ctrlKey: true,\n        bubbles: true\n      })\n\n      act(() => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockClearSelection).toHaveBeenCalled()\n      expect(mockSelectAll).not.toHaveBeenCalled()\n    })\n  })\n\n  describe('Rename (F2)', () => {\n    it('should start renaming when F2 is pressed with single selection', () => {\n      useSelectionContext.mockReturnValue({\n        selectedItems: [mockSelectedItems[0]], // Single item selected\n        selectAll: mockSelectAll,\n        hideSelectionBar: mockHideSelectionBar,\n        clearSelection: mockClearSelection,\n        isSelectAll: false\n      })\n\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'F2',\n        bubbles: true\n      })\n\n      act(() => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockDispatch).toHaveBeenCalledWith(\n        startRenamingAsync(mockSelectedItems[0])\n      )\n    })\n\n    it('should not start renaming when multiple items are selected', () => {\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'F2',\n        bubbles: true\n      })\n\n      act(() => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockDispatch).not.toHaveBeenCalled()\n    })\n  })\n\n  describe('the delete shortcut key', () => {\n    it('should show delete confirmation when Delete key is pressed', () => {\n      const mockPushModal = jest.fn()\n      const mockPopModal = jest.fn()\n      const mockRefresh = jest.fn()\n\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems,\n            pushModal: mockPushModal,\n            popModal: mockPopModal,\n            refresh: mockRefresh\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'Delete',\n        bubbles: true\n      })\n\n      act(() => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockPushModal).toHaveBeenCalledWith(\n        expect.objectContaining({\n          type: expect.any(Function)\n        })\n      )\n    })\n\n    it('should not show delete confirmation when no items are selected', () => {\n      useSelectionContext.mockReturnValue({\n        selectedItems: [],\n        selectAll: mockSelectAll,\n        hideSelectionBar: mockHideSelectionBar,\n        clearSelection: mockClearSelection,\n        isSelectAll: false\n      })\n\n      const mockPushModal = jest.fn()\n      const mockPopModal = jest.fn()\n      const mockRefresh = jest.fn()\n\n      const wrapper = createWrapper()\n      renderHook(\n        () =>\n          useKeyboardShortcuts({\n            client: mockClient,\n            items: mockItems,\n            pushModal: mockPushModal,\n            popModal: mockPopModal,\n            refresh: mockRefresh\n          }),\n        { wrapper }\n      )\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'Delete',\n        bubbles: true\n      })\n\n      act(() => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockPushModal).not.toHaveBeenCalled()\n    })\n  })\n\n  describe('Shared Drive Operations', () => {\n    const sharedDriveFiles = [\n      {\n        _id: 'shared-file-1',\n        name: 'shared-doc.pdf',\n        type: 'file',\n        dir_id: 'shared-folder-1',\n        driveId: 'shared-drive-123'\n      }\n    ]\n\n    const sharedDriveFolder = {\n      _id: 'shared-folder-1',\n      name: 'Shared Folder',\n      type: 'directory',\n      driveId: 'shared-drive-456'\n    }\n\n    beforeEach(() => {\n      // Reset all mocks\n      shouldBlockKeyboardShortcuts.mockReturnValue(false)\n      isFile.mockReturnValue(true)\n\n      useDisplayedFolder.mockReturnValue({ displayedFolder: sharedDriveFolder })\n      useSelectionContext.mockReturnValue({\n        selectedItems: sharedDriveFiles,\n        selectAll: mockSelectAll,\n        clearSelection: mockClearSelection,\n        isSelectionBarVisible: false\n      })\n    })\n\n    it('should copy shared drive files when Ctrl+C is pressed', () => {\n      const wrapper = createWrapper()\n      renderHook(() => useKeyboardShortcuts({ onPaste: mockOnPaste }), {\n        wrapper\n      })\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'c',\n        ctrlKey: true,\n        bubbles: true\n      })\n\n      act(() => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockCopyFiles).toHaveBeenCalledWith(\n        sharedDriveFiles,\n        new Set(['shared-folder-1'])\n      )\n      expect(mockShowAlert).toHaveBeenCalledWith({\n        message: 'alert.item_copied',\n        severity: 'success'\n      })\n      expect(mockClearSelection).toHaveBeenCalled()\n    })\n\n    it('should cut shared drive files when Ctrl+X is pressed', () => {\n      useDisplayedFolder.mockReturnValue({ displayedFolder: sharedDriveFolder })\n\n      const wrapper = createWrapper()\n      renderHook(() => useKeyboardShortcuts({ onPaste: mockOnPaste }), {\n        wrapper\n      })\n\n      const event = new KeyboardEvent('keydown', {\n        key: 'x',\n        ctrlKey: true,\n        bubbles: true\n      })\n\n      act(() => {\n        document.dispatchEvent(event)\n      })\n\n      expect(mockCutFiles).toHaveBeenCalledWith(\n        sharedDriveFiles,\n        new Set(['shared-folder-1']),\n        sharedDriveFolder\n      )\n      expect(mockShowAlert).toHaveBeenCalledWith({\n        message: 'alert.item_cut',\n        severity: 'success'\n      })\n      expect(mockClearSelection).toHaveBeenCalled()\n    })\n\n    it('should handle paste operations with shared drive folders', async () => {\n      // Test that handlePasteOperation can be called with shared drive folder\n      // This verifies the integration works correctly\n      await handlePasteOperation(\n        mockClient,\n        [{ _id: 'regular-file', name: 'regular.txt' }],\n        'copy',\n        null,\n        sharedDriveFolder,\n        {\n          sharingContext: null,\n          showAlert: mockShowAlert,\n          showMoveValidationModal: mockShowMoveValidationModal,\n          t: mockT\n        }\n      )\n\n      // Verify that the function was called with shared drive folder\n      expect(handlePasteOperation).toHaveBeenCalledWith(\n        mockClient,\n        [{ _id: 'regular-file', name: 'regular.txt' }],\n        'copy',\n        null,\n        expect.objectContaining({\n          _id: 'shared-folder-1',\n          driveId: 'shared-drive-456'\n        }),\n        expect.any(Object)\n      )\n    })\n  })\n})\n"
  },
  {
    "path": "src/hooks/useKeyboardShortcuts.tsx",
    "content": "import React, { useEffect, useCallback } from 'react'\nimport { useDispatch } from 'react-redux'\n\nimport { isFile } from 'cozy-client/dist/models/file'\nimport CozyClient from 'cozy-client/types/CozyClient'\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { shouldBlockKeyboardShortcuts, normalizeKey } from './helpers'\n\nimport { isMacOS } from '@/components/pushClient'\nimport { SHARED_DRIVES_DIR_ID } from '@/constants/config'\nimport {\n  useClipboardContext,\n  OPERATION_CUT\n} from '@/contexts/ClipboardProvider'\nimport { useDisplayedFolder } from '@/hooks'\nimport DeleteConfirm from '@/modules/drive/DeleteConfirm'\nimport { startRenamingAsync } from '@/modules/drive/rename'\nimport { useNextcloudCurrentFolder } from '@/modules/nextcloud/hooks/useNextcloudCurrentFolder'\nimport { handlePasteOperation } from '@/modules/paste'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\n// Type for the result returned by copy/move operations from cozy-client\ninterface PasteResultFile {\n  data?: IOCozyFile\n  moved?: IOCozyFile\n}\n\ninterface PasteOperationResult {\n  success: boolean\n  file: PasteResultFile | IOCozyFile\n  error?: Error\n  operation: string\n}\n\ninterface UseKeyboardShortcutsProps {\n  onPaste?: (() => void) | null\n  canPaste?: boolean\n  client?: CozyClient | null\n  items?: IOCozyFile[]\n  sharingContext?: unknown\n  allowCopy?: boolean\n  allowCut?: boolean\n  allowDelete?: boolean\n  isNextCloudFolder?: boolean\n  isPublic?: boolean\n  pushModal?: (modal: React.ReactElement) => void\n  popModal?: () => void\n  refresh?: () => void\n}\n\nexport const useKeyboardShortcuts = ({\n  onPaste = null,\n  canPaste = false,\n  client = null,\n  items = [],\n  sharingContext = null,\n  allowCopy = true,\n  allowCut = true,\n  allowDelete = true,\n  isNextCloudFolder = false,\n  isPublic = false,\n  pushModal,\n  popModal,\n  refresh\n}: UseKeyboardShortcutsProps): void => {\n  const dispatch = useDispatch()\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n  const {\n    selectedItems,\n    selectAll,\n    hideSelectionBar,\n    clearSelection,\n    isSelectAll\n  } = useSelectionContext() as unknown as {\n    selectedItems: IOCozyFile[]\n    selectAll: (items: IOCozyFile[]) => void\n    hideSelectionBar: () => void\n    clearSelection: () => void\n    isSelectAll: boolean\n  }\n  const {\n    clipboardData,\n    copyFiles,\n    cutFiles,\n    clearClipboard,\n    hasClipboardData,\n    showMoveValidationModal\n  } = useClipboardContext()\n  const { addItems } = useNewItemHighlightContext() as {\n    addItems: (items: IOCozyFile[]) => void\n  }\n\n  const { displayedFolder } = useDisplayedFolder()\n  const currentNextCloudFolder = useNextcloudCurrentFolder()\n  const currentFolder = isNextCloudFolder\n    ? currentNextCloudFolder\n    : displayedFolder\n\n  const isApple = isMacOS()\n\n  const handleCopy = useCallback(() => {\n    if (!allowCopy) {\n      showAlert({ message: t('alert.copy_not_allowed'), severity: 'secondary' })\n      return\n    }\n\n    const parentFolderIds = selectedItems.map(item => item.dir_id)\n    if (parentFolderIds.includes(SHARED_DRIVES_DIR_ID)) {\n      showAlert({\n        message: t('alert.cannot_copy_shared_drive'),\n        severity: 'secondary'\n      })\n      return\n    }\n\n    if (!selectedItems.length) return\n\n    const filesToCopy = selectedItems.filter(isFile)\n    if (filesToCopy.length === 0) {\n      showAlert({ message: t('alert.copy_files_only'), severity: 'secondary' })\n      return\n    }\n\n    copyFiles(filesToCopy, new Set(parentFolderIds))\n    const message =\n      filesToCopy.length === 1\n        ? t('alert.item_copied')\n        : t('alert.items_copied', { count: filesToCopy.length })\n    showAlert({ message, severity: 'success' })\n    clearSelection()\n  }, [allowCopy, selectedItems, copyFiles, showAlert, t, clearSelection])\n\n  const handleCut = useCallback(() => {\n    if (!selectedItems.length) return\n\n    if (!allowCut) {\n      showAlert({\n        message: t('alert.cut_not_allowed'),\n        severity: 'secondary'\n      })\n      return\n    }\n\n    const parentFolderIds = selectedItems.map(item => item.dir_id)\n\n    if (parentFolderIds.includes(SHARED_DRIVES_DIR_ID)) {\n      showAlert({\n        message: t('alert.cannot_move_shared_drive'),\n        severity: 'secondary'\n      })\n      return\n    }\n\n    cutFiles(\n      selectedItems,\n      new Set(parentFolderIds),\n      currentFolder as IOCozyFile\n    )\n    const message =\n      selectedItems.length === 1\n        ? t('alert.item_cut')\n        : t('alert.items_cut', { count: selectedItems.length })\n    showAlert({ message, severity: 'success' })\n    clearSelection()\n  }, [\n    selectedItems,\n    allowCut,\n    currentFolder,\n    cutFiles,\n    t,\n    showAlert,\n    clearSelection\n  ])\n\n  const handlePaste = useCallback(async () => {\n    if (!hasClipboardData || !client || !currentFolder) return\n\n    if (!canPaste) {\n      showAlert({\n        message: t('alert.paste_not_allowed'),\n        severity: 'secondary'\n      })\n      return\n    }\n\n    // Skip operation if cutting and pasting in the same folder\n    if (\n      clipboardData.operation === OPERATION_CUT &&\n      clipboardData.sourceFolderIds?.has(currentFolder._id)\n    ) {\n      showAlert({\n        message: t('alert.paste_same_folder_skipped'),\n        severity: 'secondary'\n      })\n      return\n    }\n\n    try {\n      const results = (await handlePasteOperation(\n        client,\n        clipboardData.files,\n        clipboardData.operation,\n        clipboardData.sourceDirectory,\n        currentFolder,\n        {\n          showAlert,\n          t,\n          sharingContext,\n          showMoveValidationModal,\n          isPublic\n        }\n      )) as PasteOperationResult[]\n\n      const successCount = results.filter(r => r.success).length\n      const failureCount = results.filter(r => !r.success).length\n\n      if (successCount > 0) {\n        const message =\n          successCount === 1\n            ? t('alert.item_pasted')\n            : t('alert.items_pasted', { count: successCount })\n        showAlert({ message, severity: 'success' })\n\n        const successfulFiles = results\n          .filter(r => r.success)\n          .map(r => {\n            const file = r.file\n            if ('data' in file && file.data) {\n              return file.data\n            }\n            if ('moved' in file && file.moved) {\n              return file.moved\n            }\n            return null\n          })\n          .filter((file): file is IOCozyFile => file !== null)\n\n        if (successfulFiles.length > 0) {\n          addItems(successfulFiles)\n        }\n      } else if (failureCount > 0) {\n        showAlert({\n          message: t('alert.paste_failed'),\n          severity: 'error'\n        })\n      }\n\n      if (clipboardData.operation === OPERATION_CUT) {\n        clearClipboard()\n      }\n\n      onPaste?.()\n    } catch (_error) {\n      showAlert({\n        message: t('alert.paste_error'),\n        severity: 'error'\n      })\n    }\n  }, [\n    hasClipboardData,\n    client,\n    currentFolder,\n    canPaste,\n    clipboardData.operation,\n    clipboardData.sourceFolderIds,\n    clipboardData.files,\n    clipboardData.sourceDirectory,\n    showAlert,\n    t,\n    sharingContext,\n    showMoveValidationModal,\n    isPublic,\n    onPaste,\n    clearClipboard,\n    addItems\n  ])\n\n  const handleSelectAll = useCallback(() => {\n    if (isSelectAll) {\n      clearSelection()\n    } else {\n      selectAll(items)\n    }\n  }, [isSelectAll, clearSelection, selectAll, items])\n\n  const handleRename = useCallback(() => {\n    if (selectedItems.length === 1) {\n      dispatch(startRenamingAsync(selectedItems[0]))\n    }\n  }, [selectedItems, dispatch])\n\n  const handleEscape = useCallback(() => {\n    hideSelectionBar()\n    clearClipboard()\n  }, [hideSelectionBar, clearClipboard])\n\n  const handleDelete = useCallback(() => {\n    if (!selectedItems.length || !pushModal || !popModal || !refresh) return\n\n    if (!allowDelete) {\n      showAlert({\n        message: t('alert.delete_not_allowed'),\n        severity: 'secondary'\n      })\n      return\n    }\n\n    const driveId = selectedItems[0]?.driveId\n\n    pushModal(\n      <DeleteConfirm\n        files={selectedItems}\n        afterConfirmation={refresh}\n        onClose={popModal}\n        driveId={driveId}\n      />\n    )\n  }, [selectedItems, pushModal, popModal, refresh, allowDelete, showAlert, t])\n\n  useEffect(() => {\n    const shortcuts: Record<string, (() => void | Promise<void>) | undefined> =\n      {\n        'Ctrl+c': handleCopy,\n        'Ctrl+x': handleCut,\n        'Ctrl+v': handlePaste,\n        'Ctrl+a': handleSelectAll,\n        f2: handleRename,\n        escape: handleEscape,\n        delete: handleDelete\n      }\n\n    const handleKeyDown = (event: KeyboardEvent): void => {\n      if (!event.target || shouldBlockKeyboardShortcuts(event.target)) return\n\n      const combo = normalizeKey(event, isApple)\n      const handler = shortcuts[combo]\n\n      if (handler) {\n        event.preventDefault()\n        void handler()\n      }\n    }\n\n    document.addEventListener('keydown', handleKeyDown)\n\n    return (): void => document.removeEventListener('keydown', handleKeyDown)\n  }, [\n    isApple,\n    handleCopy,\n    handleCut,\n    handlePaste,\n    handleSelectAll,\n    handleRename,\n    handleEscape,\n    handleDelete\n  ])\n}\n"
  },
  {
    "path": "src/hooks/useMoreMenuActions.jsx",
    "content": "import { useState, useEffect } from 'react'\nimport { useLocation, useNavigate } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport { fetchBlobFileById, isFile } from 'cozy-client/dist/models/file'\nimport { useWebviewIntent } from 'cozy-intent'\nimport { useVaultClient } from 'cozy-keys-lib'\nimport {\n  useSharingContext,\n  useNativeFileSharing,\n  shareNative,\n  addToCozySharingLink,\n  syncToCozySharingLink,\n  useSharingInfos\n} from 'cozy-sharing'\nimport {\n  makeActions,\n  print\n} from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { useCurrentFolderId } from '@/hooks'\nimport { useModalContext } from '@/lib/ModalContext'\nimport { share, download, trash, versions, hr } from '@/modules/actions'\nimport { addToFavorites } from '@/modules/actions/components/addToFavorites'\nimport { duplicateTo } from '@/modules/actions/components/duplicateTo'\nimport { moveTo } from '@/modules/actions/components/moveTo'\nimport { removeFromFavorites } from '@/modules/actions/components/removeFromFavorites'\nimport { details } from '@/modules/actions/details'\nimport { filterActionsByPolicy } from '@/modules/actions/policies'\n\nexport const useMoreMenuActions = file => {\n  const [isPrintAvailable, setIsPrintAvailable] = useState(false)\n  const client = useClient()\n  const vaultClient = useVaultClient()\n  const webviewIntent = useWebviewIntent()\n  const { t, lang } = useI18n()\n  const { isMobile } = useBreakpoints()\n  const navigate = useNavigate()\n  const { pushModal, popModal } = useModalContext()\n  const { allLoaded, hasWriteAccess, isOwner, byDocId } = useSharingContext()\n  const { showAlert } = useAlert()\n  const { isNativeFileSharingAvailable, shareFilesNative } =\n    useNativeFileSharing()\n  const currentFolderId = useCurrentFolderId()\n  const { isSharingShortcutCreated, addSharingLink, syncSharingLink } =\n    useSharingInfos()\n  const location = useLocation()\n  const canWriteToCurrentFolder = hasWriteAccess(currentFolderId, file.driveId)\n  const isPDFDoc = file.mime === 'application/pdf'\n  const showPrintAction = isPDFDoc && isPrintAvailable\n  const isCozySharing = window.location.pathname === '/preview'\n\n  const actions = makeActions(\n    [\n      share,\n      shareNative,\n      isCozySharing && addToCozySharingLink,\n      isCozySharing && syncToCozySharingLink,\n      download,\n      showPrintAction && print,\n      details,\n      hr,\n      moveTo,\n      duplicateTo,\n      addToFavorites,\n      removeFromFavorites,\n      hr,\n      versions,\n      hr,\n      trash\n    ],\n    {\n      client,\n      t,\n      lang,\n      vaultClient,\n      pushModal,\n      popModal,\n      refresh: () => navigate('..'),\n      navigate,\n      hasWriteAccess: canWriteToCurrentFolder,\n      canMove: canWriteToCurrentFolder,\n      isPublic: false,\n      allLoaded,\n      showAlert,\n      isOwner,\n      byDocId,\n      isNativeFileSharingAvailable,\n      shareFilesNative,\n      isSharingShortcutCreated,\n      openSharingLinkDisplayed: isCozySharing,\n      syncSharingLink,\n      isMobile,\n      fetchBlobFileById,\n      isFile,\n      addSharingLink,\n      driveId: file.driveId,\n      location\n    }\n  )\n\n  const filteredActions = filterActionsByPolicy(actions, [file])\n\n  useEffect(() => {\n    const init = async () => {\n      const isAvailable =\n        (await webviewIntent?.call('isAvailable', 'print')) ?? true\n\n      setIsPrintAvailable(isAvailable)\n    }\n\n    init()\n  }, [webviewIntent])\n\n  return filteredActions\n}\n"
  },
  {
    "path": "src/hooks/useOnLongPress/helpers.js",
    "content": "const DOUBLECLICKDELAY = 400\n\nexport const handleClick = ({\n  event,\n  file,\n  disabled,\n  isRenaming,\n  openLink,\n  toggle,\n  lastClickTime,\n  setLastClickTime,\n  setSelectedItems,\n  onInteractWithFile,\n  clearHighlightedItems\n}) => {\n  // if default behavior is opening a file, it blocks that to force other bahavior\n  event.preventDefault()\n\n  if (disabled || isRenaming) return\n\n  clearHighlightedItems?.()\n\n  const currentTime = Date.now()\n  const isDoubleClick = currentTime - lastClickTime < DOUBLECLICKDELAY\n\n  if (isDoubleClick) {\n    openLink(event)\n  } else if (event.ctrlKey || event.metaKey) {\n    toggle(event)\n  } else {\n    // we should not use file.index\n    // we should probablt not use index - 1\n    // we should use only one func to set things on click, and not 3 setters\n    setSelectedItems({ [file._id]: file })\n  }\n\n  onInteractWithFile?.(file._id, event)\n  setLastClickTime(currentTime)\n}\n\nexport const makeDesktopHandlers = ({\n  file,\n  timerId,\n  disabled,\n  isRenaming,\n  openLink,\n  toggle,\n  selectionModeActive,\n  lastClickTime,\n  setLastClickTime,\n  clearSelection,\n  setSelectedItems,\n  clearHighlightedItems,\n  onInteractWithFile\n}) => {\n  return {\n    // first event triggered on Desktop\n    onMouseDown: () => clearTimeout(timerId.current),\n    // second event triggered on Desktop\n    onMouseUp: () => clearTimeout(timerId.current),\n    // third event triggered on Desktop\n    onClick: event =>\n      handleClick({\n        event,\n        file,\n        disabled,\n        isRenaming,\n        openLink,\n        toggle,\n        selectionModeActive,\n        lastClickTime,\n        setLastClickTime,\n        clearSelection,\n        setSelectedItems,\n        clearHighlightedItems,\n        onInteractWithFile\n      })\n  }\n}\n\nexport const handlePress = ({\n  event,\n  disabled,\n  selectionModeActive,\n  isLongPress,\n  isRenaming,\n  openLink,\n  toggle,\n  clearHighlightedItems\n}) => {\n  // if default behavior is opening a file, it blocks that to force other bahavior\n  event.preventDefault()\n\n  // isLongPress is to prevent executing onPress twice while a longpress\n  // can happen if button is released quickly just after startPressTimer execution\n  if (disabled || isLongPress.current || isRenaming) return\n\n  if (selectionModeActive) {\n    toggle(event)\n  } else {\n    openLink(event)\n  }\n\n  clearHighlightedItems?.()\n}\n\nexport const makeMobileHandlers = ({\n  timerId,\n  disabled,\n  selectionModeActive,\n  isRenaming,\n  isLongPress,\n  openLink,\n  toggle,\n  clearHighlightedItems\n}) => {\n  // used to determine if it's a longpress\n  // i.e. delay onClick\n  const startPressTimer = e => {\n    e.persist()\n    isLongPress.current = false\n    timerId.current = setTimeout(() => {\n      isLongPress.current = true\n      if (!isRenaming) {\n        toggle(e)\n      }\n    }, 250)\n  }\n\n  return {\n    // first event triggered on Mobile when taping an item\n    onTouchStart: startPressTimer,\n    // second event triggered on Mobile when dragging an item\n    onTouchMove: () => clearTimeout(timerId.current),\n    // third event triggered on Mobile when taping an item\n    onTouchEnd: () => clearTimeout(timerId.current),\n    // fourth event triggered on Mobile\n    onClick: event =>\n      handlePress({\n        event,\n        disabled,\n        selectionModeActive,\n        isLongPress,\n        isRenaming,\n        openLink,\n        toggle,\n        clearHighlightedItems\n      })\n  }\n}\n"
  },
  {
    "path": "src/hooks/useOnLongPress/helpers.spec.jsx",
    "content": "import MockDate from 'mockdate'\n\nimport flag from 'cozy-flags'\n\nimport { handlePress, handleClick } from './helpers'\n\njest.mock('cozy-flags', () => jest.fn())\n\nconst mockToggle = jest.fn()\nconst mockOpenLink = jest.fn()\nconst ev = { preventDefault: jest.fn() }\n\ndescribe('handlePress', () => {\n  const setup = ({\n    event = ev,\n    disabled = false,\n    selectionModeActive = false,\n    isLongPress = { current: false },\n    isRenaming = false\n  }) => {\n    return {\n      params: {\n        event,\n        disabled,\n        selectionModeActive,\n        isLongPress,\n        isRenaming,\n        openLink: mockOpenLink,\n        toggle: mockToggle\n      }\n    }\n  }\n\n  afterEach(() => {\n    jest.clearAllMocks()\n  })\n\n  it('should only toggle if selectionModeActive', () => {\n    const { params } = setup({ selectionModeActive: true })\n    handlePress(params)\n\n    expect(mockToggle).toHaveBeenCalledWith(ev)\n    expect(mockOpenLink).not.toHaveBeenCalled()\n  })\n\n  it('should only open link if not renaming', () => {\n    const { params } = setup({ isRenaming: false })\n    handlePress(params)\n\n    expect(mockToggle).not.toHaveBeenCalledWith()\n    expect(mockOpenLink).toHaveBeenCalledWith(ev)\n  })\n\n  describe('should do nothing if', () => {\n    it('disabled is true', () => {\n      const { params } = setup({ disabled: true })\n      handlePress(params)\n\n      expect(mockToggle).not.toHaveBeenCalled()\n      expect(mockOpenLink).not.toHaveBeenCalled()\n    })\n\n    it('isRenaming is true', () => {\n      const { params } = setup({ isRenaming: true })\n      handlePress(params)\n\n      expect(mockToggle).not.toHaveBeenCalledWith()\n      expect(mockOpenLink).not.toHaveBeenCalled()\n    })\n\n    it('isLongPress is true', () => {\n      const { params } = setup({ isLongPress: { current: true } })\n      handlePress(params)\n\n      expect(mockToggle).not.toHaveBeenCalledWith()\n      expect(mockOpenLink).not.toHaveBeenCalled()\n    })\n  })\n})\n\ndescribe('handleClick', () => {\n  const setup = ({\n    event = ev,\n    disabled = false,\n    isRenaming = false,\n    file = { _id: 'file-id' },\n    lastClickTime = new Date('2025-01-01T12:00:00.000Z').getTime() // date of the first click\n  }) => {\n    return {\n      params: {\n        event,\n        disabled,\n        isRenaming,\n        file,\n        openLink: mockOpenLink,\n        toggle: mockToggle,\n        lastClickTime,\n        setLastClickTime: jest.fn(),\n        setSelectedItems: jest.fn(),\n        onInteractWithFile: jest.fn()\n      }\n    }\n  }\n\n  afterEach(() => {\n    jest.clearAllMocks()\n    MockDate.reset()\n  })\n\n  // should create a real life test to replace toggle by final func\n  xit('should only toggle by default', () => {\n    const { params } = setup({})\n    handleClick(params)\n\n    expect(mockToggle).toHaveBeenCalledWith(ev)\n    expect(mockOpenLink).not.toHaveBeenCalled()\n  })\n\n  describe('should do nothing if', () => {\n    it('disabled is true', () => {\n      const { params } = setup({ disabled: true })\n      handleClick(params)\n\n      expect(mockToggle).not.toHaveBeenCalled()\n      expect(mockOpenLink).not.toHaveBeenCalled()\n    })\n\n    it('isRenaming is true', () => {\n      const { params } = setup({ isRenaming: true })\n      handleClick(params)\n\n      expect(mockToggle).not.toHaveBeenCalledWith()\n      expect(mockOpenLink).not.toHaveBeenCalled()\n    })\n  })\n\n  describe('with dynamic-selection enabled and selectionModeActive', () => {\n    const file = { _id: 'file-1' }\n    const mockSetSelectedItems = jest.fn()\n    const mockOnInteractWithFile = jest.fn()\n\n    const setupDynamic = (eventOverrides = {}) => {\n      flag.mockImplementation(name => {\n        if (name === 'drive.dynamic-selection.enabled') return true\n        if (name === 'drive.doubleclick.enabled') return false\n        return false\n      })\n\n      const event = {\n        preventDefault: jest.fn(),\n        stopPropagation: jest.fn(),\n        shiftKey: false,\n        ctrlKey: false,\n        metaKey: false,\n        ...eventOverrides\n      }\n\n      return {\n        params: {\n          event,\n          file,\n          disabled: false,\n          isRenaming: false,\n          openLink: mockOpenLink,\n          toggle: mockToggle,\n          selectionModeActive: true,\n          lastClickTime: 0,\n          setLastClickTime: jest.fn(),\n          setSelectedItems: mockSetSelectedItems,\n          onInteractWithFile: mockOnInteractWithFile,\n          clearHighlightedItems: jest.fn()\n        },\n        event\n      }\n    }\n\n    afterEach(() => {\n      flag.mockReset()\n    })\n\n    it('should replace selection on simple click', () => {\n      const { params } = setupDynamic()\n      handleClick(params)\n\n      expect(mockSetSelectedItems).toHaveBeenCalledWith({\n        [file._id]: file\n      })\n      expect(mockToggle).not.toHaveBeenCalled()\n    })\n\n    it('should toggle item on Ctrl+Click', () => {\n      const { params, event } = setupDynamic({ ctrlKey: true })\n      handleClick(params)\n\n      expect(mockToggle).toHaveBeenCalledWith(event)\n      expect(mockSetSelectedItems).not.toHaveBeenCalled()\n    })\n\n    it('should toggle item on Cmd+Click (metaKey)', () => {\n      const { params, event } = setupDynamic({ metaKey: true })\n      handleClick(params)\n\n      expect(mockToggle).toHaveBeenCalledWith(event)\n      expect(mockSetSelectedItems).not.toHaveBeenCalled()\n    })\n  })\n\n  describe('with doubleclick enabled', () => {\n    const file = { _id: 'file-1' }\n    const mockSetSelectedItems = jest.fn()\n    const mockOnInteractWithFile = jest.fn()\n\n    const setupDoubleClick = (eventOverrides = {}) => {\n      flag.mockImplementation(name => {\n        if (name === 'drive.doubleclick.enabled') return true\n        return false\n      })\n\n      const event = {\n        preventDefault: jest.fn(),\n        stopPropagation: jest.fn(),\n        shiftKey: false,\n        ctrlKey: false,\n        metaKey: false,\n        ...eventOverrides\n      }\n\n      return {\n        params: {\n          event,\n          file,\n          disabled: false,\n          isRenaming: false,\n          openLink: mockOpenLink,\n          toggle: mockToggle,\n          selectionModeActive: true,\n          lastClickTime: 0,\n          setLastClickTime: jest.fn(),\n          setSelectedItems: mockSetSelectedItems,\n          onInteractWithFile: mockOnInteractWithFile,\n          clearHighlightedItems: jest.fn()\n        },\n        event\n      }\n    }\n\n    afterEach(() => {\n      flag.mockReset()\n    })\n\n    it('should replace selection on simple click', () => {\n      const { params } = setupDoubleClick()\n      handleClick(params)\n\n      expect(mockSetSelectedItems).toHaveBeenCalledWith({\n        [file._id]: file\n      })\n      expect(mockToggle).not.toHaveBeenCalled()\n    })\n\n    it('should toggle item on Ctrl+Click', () => {\n      const { params, event } = setupDoubleClick({ ctrlKey: true })\n      handleClick(params)\n\n      expect(mockToggle).toHaveBeenCalledWith(event)\n      expect(mockSetSelectedItems).not.toHaveBeenCalled()\n    })\n\n    it('should toggle item on Cmd+Click (metaKey)', () => {\n      const { params, event } = setupDoubleClick({ metaKey: true })\n      handleClick(params)\n\n      expect(mockToggle).toHaveBeenCalledWith(event)\n      expect(mockSetSelectedItems).not.toHaveBeenCalled()\n    })\n  })\n\n  describe('for double click', () => {\n    beforeEach(() => {\n      MockDate.set('2025-01-01T12:00:00.300Z') // date of the second click\n    })\n\n    it('it should do nothing when renainming', () => {\n      const { params } = setup({ isRenaming: true })\n      handleClick(params)\n\n      expect(mockToggle).not.toHaveBeenCalled()\n      expect(mockOpenLink).not.toHaveBeenCalled()\n    })\n\n    it('it should only open link', () => {\n      const { params } = setup({})\n      handleClick(params)\n\n      expect(mockToggle).not.toHaveBeenCalled()\n      expect(mockOpenLink).toHaveBeenCalledWith(ev)\n    })\n  })\n})\n"
  },
  {
    "path": "src/hooks/useOnLongPress/index.js",
    "content": "import { useRef, useState } from 'react'\n\nimport { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { makeDesktopHandlers, makeMobileHandlers } from './helpers'\n\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\nexport const useLongPress = ({\n  file,\n  disabled,\n  isRenaming,\n  openLink,\n  toggle,\n  onInteractWithFile\n}) => {\n  const timerId = useRef()\n  const isLongPress = useRef(false)\n  const [lastClickTime, setLastClickTime] = useState(0)\n  const { isDesktop } = useBreakpoints()\n  const {\n    setSelectedItems,\n    clearSelection,\n    isSelectionBarVisible: selectionModeActive\n  } = useSelectionContext()\n  const { clearItems: clearHighlightedItems } = useNewItemHighlightContext()\n\n  if (isDesktop) {\n    // eslint-disable-next-line react-hooks/refs\n    return makeDesktopHandlers({\n      file,\n      timerId,\n      disabled,\n      isRenaming,\n      openLink,\n      toggle,\n      selectionModeActive,\n      lastClickTime,\n      setLastClickTime,\n      clearSelection,\n      setSelectedItems,\n      onInteractWithFile,\n      clearHighlightedItems\n    })\n  }\n\n  // eslint-disable-next-line react-hooks/refs\n  return makeMobileHandlers({\n    timerId,\n    disabled,\n    selectionModeActive,\n    isRenaming,\n    isLongPress,\n    openLink,\n    toggle,\n    clearHighlightedItems\n  })\n}\n"
  },
  {
    "path": "src/hooks/useParentFolder.jsx",
    "content": "import { useClient } from 'cozy-client'\n\nimport { DOCTYPE_FILES } from '@/lib/doctypes'\n\nconst useParentFolder = parentFolderId => {\n  const client = useClient()\n\n  if (parentFolderId) {\n    return client.getDocumentFromState(DOCTYPE_FILES, parentFolderId)\n  }\n  return null\n}\n\nexport default useParentFolder\n"
  },
  {
    "path": "src/hooks/useParentFolder.spec.jsx",
    "content": "import useParentFolder from './useParentFolder'\n\nconst mockGetDocumentFromState = jest.fn()\n\njest.mock('cozy-client', () => ({\n  ...jest.requireActual('cozy-client'),\n  useClient: () => ({\n    getDocumentFromState: mockGetDocumentFromState\n  })\n}))\n\ndescribe('useParentFolder', () => {\n  it('should return file folder if parent folder exists', () => {\n    const FOLDER = {\n      id: 'folder-id',\n      name: 'Folder name'\n    }\n    mockGetDocumentFromState.mockReturnValue(FOLDER)\n\n    const parentFolder = useParentFolder(FOLDER.id)\n\n    expect(parentFolder).toBe(FOLDER)\n  })\n\n  it('should return null if parent folder does not exist', () => {\n    const parentFolder = useParentFolder()\n\n    expect(parentFolder).toBe(null)\n  })\n})\n"
  },
  {
    "path": "src/hooks/useRecentFiles.jsx",
    "content": "import { useEffect, useState, useMemo } from 'react'\n\nimport { useClient } from 'cozy-client'\nimport { useDataProxy } from 'cozy-dataproxy-lib'\n\nimport logger from '@/lib/logger'\nimport { buildRecentQuery } from '@/queries'\n\nconst useDataProxyRecents = () => {\n  const [data, setData] = useState([])\n  const [fetchStatus, setFetchStatus] = useState('loading')\n  const [error, setError] = useState(null)\n  const dataProxy = useDataProxy()\n  const client = useClient()\n\n  const recentQuery = useMemo(() => buildRecentQuery(), [])\n\n  useEffect(() => {\n    const fetchRecents = async () => {\n      setFetchStatus('loading')\n      setError(null)\n\n      if (dataProxy.dataProxyServicesAvailable) {\n        try {\n          const data = await dataProxy.recents()\n          setData(data || [])\n          setFetchStatus('loaded')\n          return\n        } catch (err) {\n          logger.warn('Error fetching recents from dataproxy', err)\n        }\n      }\n\n      if (client) {\n        try {\n          const result = await client.fetchQueryAndGetFromState({\n            definition: recentQuery.definition(),\n            options: recentQuery.options\n          })\n          setData(result?.data || [])\n          setFetchStatus('loaded')\n        } catch (err) {\n          logger.warn('Error fetching recents from fallback query', err)\n          setError(err)\n          setFetchStatus('error')\n        }\n      } else {\n        setError(new Error('Client not available'))\n        setFetchStatus('error')\n      }\n    }\n\n    fetchRecents()\n  }, [dataProxy, client, recentQuery])\n\n  return { data, fetchStatus, error }\n}\n\nexport default useDataProxyRecents\n"
  },
  {
    "path": "src/hooks/useRecentFiles.spec.jsx",
    "content": "import { renderHook, waitFor } from '@testing-library/react'\n\nimport { useClient } from 'cozy-client'\nimport { useDataProxy } from 'cozy-dataproxy-lib'\n\nimport useDataProxyRecents from './useRecentFiles'\n\nimport logger from '@/lib/logger'\nimport { buildRecentQuery } from '@/queries'\n\njest.mock('cozy-client', () => ({\n  useClient: jest.fn()\n}))\n\njest.mock('cozy-dataproxy-lib', () => ({\n  useDataProxy: jest.fn()\n}))\n\njest.mock('@/lib/logger', () => ({\n  warn: jest.fn()\n}))\n\njest.mock('@/queries', () => ({\n  buildRecentQuery: jest.fn()\n}))\n\nconst mockUseClient = useClient\nconst mockUseDataProxy = useDataProxy\nconst mockBuildRecentQuery = buildRecentQuery\n\ndescribe('useDataProxyRecents', () => {\n  let mockClient\n\n  beforeEach(() => {\n    jest.clearAllMocks()\n    mockClient = {\n      fetchQueryAndGetFromState: jest.fn()\n    }\n    mockUseClient.mockReturnValue(mockClient)\n    mockBuildRecentQuery.mockReturnValue({\n      definition: jest.fn(() => ({})),\n      options: {}\n    })\n  })\n\n  describe('when dataProxy is available and succeeds', () => {\n    it('should return data from dataProxy', async () => {\n      const mockData = [\n        { id: '1', name: 'file1' },\n        { id: '2', name: 'file2' }\n      ]\n      const mockDataProxy = {\n        dataProxyServicesAvailable: true,\n        recents: jest.fn().mockResolvedValue(mockData)\n      }\n\n      mockUseDataProxy.mockReturnValue(mockDataProxy)\n\n      const { result } = renderHook(() => useDataProxyRecents())\n\n      expect(result.current.fetchStatus).toBe('loading')\n      expect(result.current.data).toEqual([])\n\n      await waitFor(() => expect(result.current.fetchStatus).toBe('loaded'))\n      expect(result.current.data).toEqual(mockData)\n      expect(result.current.error).toBe(null)\n      expect(mockDataProxy.recents).toHaveBeenCalledTimes(1)\n      expect(mockClient.fetchQueryAndGetFromState).not.toHaveBeenCalled()\n      expect(logger.warn).not.toHaveBeenCalled()\n    })\n  })\n\n  describe('when dataProxy throws an error', () => {\n    it('should use fallback query when dataProxy fails', async () => {\n      const mockError = new Error('DataProxy error')\n      const mockDataProxy = {\n        dataProxyServicesAvailable: true,\n        recents: jest.fn().mockRejectedValue(mockError)\n      }\n      const fallbackData = [\n        { id: '3', name: 'file3' },\n        { id: '4', name: 'file4' }\n      ]\n\n      mockUseDataProxy.mockReturnValue(mockDataProxy)\n      mockClient.fetchQueryAndGetFromState.mockResolvedValue({\n        data: fallbackData\n      })\n\n      const { result } = renderHook(() => useDataProxyRecents())\n\n      expect(result.current.fetchStatus).toBe('loading')\n      expect(result.current.data).toEqual([])\n\n      // Wait for fallback query to complete\n      await waitFor(() => expect(result.current.fetchStatus).toBe('loaded'))\n      expect(result.current.data).toEqual(fallbackData)\n      expect(result.current.error).toBe(null)\n      expect(logger.warn).toHaveBeenCalledWith(\n        'Error fetching recents from dataproxy',\n        mockError\n      )\n      expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledTimes(1)\n      expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledWith({\n        definition: expect.any(Object),\n        options: expect.any(Object)\n      })\n    })\n\n    it('should handle fallback query error', async () => {\n      const mockError = new Error('DataProxy error')\n      const fallbackError = new Error('Fallback query error')\n      const mockDataProxy = {\n        dataProxyServicesAvailable: true,\n        recents: jest.fn().mockRejectedValue(mockError)\n      }\n\n      mockUseDataProxy.mockReturnValue(mockDataProxy)\n      mockClient.fetchQueryAndGetFromState.mockRejectedValue(fallbackError)\n\n      const { result } = renderHook(() => useDataProxyRecents())\n\n      // Wait for fallback query error to be processed\n      await waitFor(() => expect(result.current.fetchStatus).toBe('error'))\n      expect(result.current.error).toEqual(fallbackError)\n      expect(logger.warn).toHaveBeenCalledWith(\n        'Error fetching recents from dataproxy',\n        mockError\n      )\n      expect(logger.warn).toHaveBeenCalledWith(\n        'Error fetching recents from fallback query',\n        fallbackError\n      )\n      expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledTimes(1)\n    })\n  })\n\n  describe('when dataProxy is not available', () => {\n    it('should use fallback query when dataProxy is not available', async () => {\n      const mockDataProxy = {\n        dataProxyServicesAvailable: false\n      }\n      const fallbackData = [\n        { id: '5', name: 'file5' },\n        { id: '6', name: 'file6' }\n      ]\n\n      mockUseDataProxy.mockReturnValue(mockDataProxy)\n      mockClient.fetchQueryAndGetFromState.mockResolvedValue({\n        data: fallbackData\n      })\n\n      const { result } = renderHook(() => useDataProxyRecents())\n\n      // When dataProxy is not available, the hook should execute fallback query\n      expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledTimes(1)\n\n      // Wait for fallback query to complete\n      await waitFor(() => expect(result.current.fetchStatus).toBe('loaded'))\n      expect(result.current.data).toEqual(fallbackData)\n      expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledWith({\n        definition: expect.any(Object),\n        options: expect.any(Object)\n      })\n    })\n\n    it('should handle fallback query loading state', async () => {\n      const mockDataProxy = {\n        dataProxyServicesAvailable: false\n      }\n\n      mockUseDataProxy.mockReturnValue(mockDataProxy)\n      // Don't resolve the query immediately to test loading state\n      mockClient.fetchQueryAndGetFromState.mockImplementation(\n        () => new Promise(() => {}) // Never resolves\n      )\n\n      const { result } = renderHook(() => useDataProxyRecents())\n\n      expect(result.current.fetchStatus).toBe('loading')\n      expect(result.current.data).toEqual([])\n      expect(result.current.error).toBe(null)\n      expect(mockClient.fetchQueryAndGetFromState).toHaveBeenCalledTimes(1)\n    })\n  })\n\n  describe('when client is not available', () => {\n    it('should set error when client is not available', async () => {\n      const mockDataProxy = {\n        dataProxyServicesAvailable: false\n      }\n\n      mockUseDataProxy.mockReturnValue(mockDataProxy)\n      mockUseClient.mockReturnValue(null)\n\n      const { result } = renderHook(() => useDataProxyRecents())\n\n      // Wait for error to be set\n      await waitFor(() => {\n        expect(result.current.fetchStatus).toBe('error')\n      })\n\n      expect(result.current.error).toEqual(new Error('Client not available'))\n      expect(result.current.data).toEqual([])\n      expect(mockClient.fetchQueryAndGetFromState).not.toHaveBeenCalled()\n    })\n  })\n})\n"
  },
  {
    "path": "src/hooks/useRecentIcons.jsx",
    "content": "import { useState, useEffect } from 'react'\n\nimport logger from '@/lib/logger'\n\nconst STORAGE_KEY = 'iconPicker_recent_icons'\nconst MAX_RECENT_ICONS = 8\n\n/**\n * Hook to get recent icons from localStorage\n * @returns {string[]} recentIcons - List of recently used icon names\n */\nexport const useRecentIcons = () => {\n  const [recentIcons, setRecentIcons] = useState(null)\n\n  useEffect(() => {\n    try {\n      const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY))\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setRecentIcons(Array.isArray(parsed) ? parsed : [])\n    } catch (error) {\n      logger.error('Failed to load recent icons from localStorage:', error)\n      setRecentIcons([])\n    }\n  }, [])\n\n  return recentIcons\n}\n\n/**\n * Add an icon to the recent icons list (for use outside of React components)\n * This function directly updates localStorage and can be called from anywhere\n * @param {string} iconName - Name of the icon to add\n */\nexport const addRecentIcon = iconName => {\n  if (!iconName || iconName === 'none') return\n\n  try {\n    const stored = localStorage.getItem(STORAGE_KEY)\n    let current = []\n\n    if (stored) {\n      const parsed = JSON.parse(stored)\n      current = Array.isArray(parsed) ? parsed : []\n    }\n\n    // Remove icon if it already exists and add it at the beginning\n    const filtered = current.filter(icon => icon !== iconName)\n    const updated = [iconName, ...filtered].slice(0, MAX_RECENT_ICONS)\n\n    localStorage.setItem(STORAGE_KEY, JSON.stringify(updated))\n  } catch (error) {\n    logger.error('Failed to save recent icons to localStorage:', error)\n  }\n}\n"
  },
  {
    "path": "src/hooks/useRecentIcons.spec.jsx",
    "content": "import { renderHook, act } from '@testing-library/react'\n\nimport { useRecentIcons, addRecentIcon } from './useRecentIcons'\n\nimport logger from '@/lib/logger'\n\njest.mock('@/lib/logger', () => ({\n  error: jest.fn()\n}))\n\nconst STORAGE_KEY = 'iconPicker_recent_icons'\nconst MAX_RECENT_ICONS = 8\n\ndescribe('useRecentIcons', () => {\n  beforeEach(() => {\n    jest.clearAllMocks()\n    localStorage.clear()\n  })\n\n  afterEach(() => {\n    localStorage.clear()\n  })\n\n  it('should return [] initially', () => {\n    const { result } = renderHook(() => useRecentIcons())\n\n    expect(result.current).toEqual([])\n  })\n\n  it('should return [] when localStorage is empty', () => {\n    const { result } = renderHook(() => useRecentIcons())\n    expect(result.current).toEqual([])\n  })\n\n  it('should return parsed array when localStorage has valid data', async () => {\n    const icons = ['icon1', 'icon2', 'icon3']\n    localStorage.setItem(STORAGE_KEY, JSON.stringify(icons))\n\n    let result\n    await act(async () => {\n      const hook = renderHook(() => useRecentIcons())\n      result = hook.result\n    })\n\n    expect(result.current).toEqual(icons)\n  })\n\n  it('should return empty array when localStorage has invalid JSON', async () => {\n    localStorage.setItem(STORAGE_KEY, 'invalid json')\n\n    let result\n    await act(async () => {\n      const hook = renderHook(() => useRecentIcons())\n      result = hook.result\n    })\n\n    expect(result.current).toEqual([])\n    expect(logger.error).toHaveBeenCalledWith(\n      'Failed to load recent icons from localStorage:',\n      expect.any(SyntaxError)\n    )\n  })\n\n  it('should return empty array when localStorage has non-array data', async () => {\n    localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: 'an array' }))\n\n    let result\n    await act(async () => {\n      const hook = renderHook(() => useRecentIcons())\n      result = hook.result\n    })\n\n    expect(result.current).toEqual([])\n  })\n\n  it('should handle localStorage.getItem errors gracefully', async () => {\n    const error = new Error('localStorage error')\n    const getItemSpy = jest\n      .spyOn(Storage.prototype, 'getItem')\n      .mockImplementation(() => {\n        throw error\n      })\n\n    let result\n    await act(async () => {\n      const hook = renderHook(() => useRecentIcons())\n      result = hook.result\n      // Wait a bit for useEffect to run and state to update\n      await new Promise(resolve => setTimeout(resolve, 10))\n    })\n\n    expect(result.current).toEqual([])\n    expect(logger.error).toHaveBeenCalledWith(\n      'Failed to load recent icons from localStorage:',\n      error\n    )\n\n    getItemSpy.mockRestore()\n  })\n})\n\ndescribe('addRecentIcon', () => {\n  beforeEach(() => {\n    jest.clearAllMocks()\n    localStorage.clear()\n  })\n\n  afterEach(() => {\n    localStorage.clear()\n  })\n\n  it('should do nothing when iconName is falsy', () => {\n    addRecentIcon(null)\n    addRecentIcon(undefined)\n    addRecentIcon('')\n\n    expect(localStorage.getItem(STORAGE_KEY)).toBeNull()\n  })\n\n  it('should do nothing when iconName is \"none\"', () => {\n    addRecentIcon('none')\n\n    expect(localStorage.getItem(STORAGE_KEY)).toBeNull()\n  })\n\n  it('should add icon to empty localStorage', () => {\n    addRecentIcon('icon1')\n\n    const stored = localStorage.getItem(STORAGE_KEY)\n    expect(stored).toBe(JSON.stringify(['icon1']))\n  })\n\n  it('should add icon to existing localStorage', () => {\n    const existingIcons = ['icon1', 'icon2']\n    localStorage.setItem(STORAGE_KEY, JSON.stringify(existingIcons))\n\n    addRecentIcon('icon3')\n\n    const stored = localStorage.getItem(STORAGE_KEY)\n    expect(stored).toBe(JSON.stringify(['icon3', 'icon1', 'icon2']))\n  })\n\n  it('should move existing icon to the beginning', () => {\n    const existingIcons = ['icon1', 'icon2', 'icon3']\n    localStorage.setItem(STORAGE_KEY, JSON.stringify(existingIcons))\n\n    addRecentIcon('icon2')\n\n    const stored = localStorage.getItem(STORAGE_KEY)\n    expect(stored).toBe(JSON.stringify(['icon2', 'icon1', 'icon3']))\n  })\n\n  it('should limit to MAX_RECENT_ICONS', () => {\n    const existingIcons = Array.from(\n      { length: MAX_RECENT_ICONS },\n      (_, i) => `icon${i + 1}`\n    )\n    localStorage.setItem(STORAGE_KEY, JSON.stringify(existingIcons))\n\n    addRecentIcon('newIcon')\n\n    const stored = localStorage.getItem(STORAGE_KEY)\n    const parsed = JSON.parse(stored)\n    expect(parsed).toHaveLength(MAX_RECENT_ICONS)\n    expect(parsed[0]).toBe('newIcon')\n    expect(parsed).not.toContain(existingIcons[existingIcons.length - 1])\n  })\n\n  it('should handle localStorage.getItem errors gracefully', () => {\n    const error = new Error('localStorage error')\n    const getItemSpy = jest\n      .spyOn(Storage.prototype, 'getItem')\n      .mockImplementation(() => {\n        throw error\n      })\n\n    addRecentIcon('icon1')\n\n    expect(logger.error).toHaveBeenCalledWith(\n      'Failed to save recent icons to localStorage:',\n      error\n    )\n\n    getItemSpy.mockRestore()\n  })\n\n  it('should handle localStorage.setItem errors gracefully', () => {\n    localStorage.setItem(STORAGE_KEY, JSON.stringify(['icon1']))\n\n    const error = new Error('localStorage setItem error')\n    const setItemSpy = jest\n      .spyOn(Storage.prototype, 'setItem')\n      .mockImplementation(() => {\n        throw error\n      })\n\n    addRecentIcon('icon2')\n\n    expect(logger.error).toHaveBeenCalledWith(\n      'Failed to save recent icons to localStorage:',\n      error\n    )\n\n    setItemSpy.mockRestore()\n  })\n\n  it('should handle invalid JSON in localStorage', () => {\n    localStorage.setItem(STORAGE_KEY, 'invalid json')\n\n    addRecentIcon('icon1')\n\n    // When JSON.parse fails, error is caught and logged, but localStorage is not updated\n    expect(logger.error).toHaveBeenCalledWith(\n      'Failed to save recent icons to localStorage:',\n      expect.any(SyntaxError)\n    )\n    // localStorage still contains the invalid JSON because the error happened before setItem\n    const stored = localStorage.getItem(STORAGE_KEY)\n    expect(stored).toBe('invalid json')\n  })\n\n  it('should handle non-array data in localStorage', () => {\n    localStorage.setItem(STORAGE_KEY, JSON.stringify({ not: 'an array' }))\n\n    addRecentIcon('icon1')\n\n    const stored = localStorage.getItem(STORAGE_KEY)\n    expect(stored).toBe(JSON.stringify(['icon1']))\n  })\n\n  it('should maintain order when adding same icon multiple times', () => {\n    addRecentIcon('icon1')\n    addRecentIcon('icon2')\n    addRecentIcon('icon3')\n    addRecentIcon('icon1')\n\n    const stored = localStorage.getItem(STORAGE_KEY)\n    expect(stored).toBe(JSON.stringify(['icon1', 'icon3', 'icon2']))\n  })\n})\n"
  },
  {
    "path": "src/hooks/useRedirectLink.jsx",
    "content": "import { useState, useEffect } from 'react'\nimport { useSearchParams, useNavigate } from 'react-router-dom'\n\nimport {\n  useClient,\n  generateWebLink,\n  deconstructRedirectLink\n} from 'cozy-client'\n\nimport { changeLocation } from '@/hooks/helpers'\nimport logger from '@/lib/logger'\n\n/**\n * @typedef {object} ReturnRedirectLink\n * @property {string} redirectLink - The redirect link\n * @property {function} redirectBack - The function to redirect the user\n * @property {boolean} canRedirect - True if the user can be redirected\n */\n\n/**\n * This hook is used to redirect from an OnlyOffice file\n * @param {boolean} isPublic - true if the file is public\n * @returns {ReturnRedirectLink} - The redirect link and the function to redirect from an OnlyOffice file\n */\nconst useRedirectLink = ({ isPublic = false } = {}) => {\n  const [searchParams] = useSearchParams()\n  const params = new URLSearchParams(location.search)\n  const client = useClient()\n  const navigate = useNavigate()\n\n  const isFromPublicFolder = searchParams.get('fromPublicFolder') === 'true'\n\n  const [currentMemberInstance, setCurrentMemberInstance] = useState(undefined)\n\n  useEffect(() => {\n    const fetch = async () => {\n      try {\n        const permissions = await client\n          .collection('io.cozy.permissions')\n          .fetchOwnPermissions()\n\n        // We gets in included the member of the sharing, corresponding to the user who accessed the file\n        // If the file is open on the instance of the share owner, we can retrieve the link to his instance\n        if (permissions.included?.length > 0) {\n          setCurrentMemberInstance(permissions.included[0].attributes?.instance)\n        }\n      } catch {\n        logger.warn('Cannot fetch permissions')\n      }\n    }\n\n    if (isPublic && !isFromPublicFolder) {\n      fetch()\n    }\n  }, [client, isPublic, isFromPublicFolder])\n\n  /**\n   * We search for redirectLink using two methods because\n   * the searchParam differs depending on the position in the url :\n   * - for /#hash?searchParam, you need useSearchParams\n   * - for /?searchParam#hash, you need location.search\n   */\n  const redirectLink =\n    searchParams.get('redirectLink') || params.get('redirectLink')\n\n  const redirectBack = () => {\n    if (!redirectLink) {\n      return logger.warn('Cannot find a redirect link')\n    }\n\n    const { slug, pathname, hash } = deconstructRedirectLink(redirectLink)\n\n    // As we navigate in the same instance, we can use the react-router-dom navigate\n    if (!isPublic || isFromPublicFolder) {\n      return navigate(hash)\n    }\n\n    // If the file is open on the instance of the share owner, we can redirect the user to his instance\n    if (currentMemberInstance) {\n      try {\n        const { subdomain: subDomainType } = client.getInstanceOptions()\n        const link = generateWebLink({\n          cozyUrl: currentMemberInstance,\n          subDomainType,\n          slug,\n          pathname,\n          hash\n        })\n        return changeLocation(link)\n      } catch (e) {\n        logger.error(`Cannot generate a web link : ${e}`)\n      }\n    }\n\n    /**\n     * If file is not open in new tab, we can redirect the user to the previous page\n     * There is a double redirection for public file :\n     * 1. To know that the file is a share, the other\n     * 2. To open it on the host instance\n     * so there is an additional entry in the history to skip to access the previous page\n     */\n    if (window.history.length > 2) {\n      return navigate(-2)\n    }\n\n    // We do nothing because we don't know where to redirect the user\n  }\n\n  const canRedirect =\n    !!redirectLink &&\n    (!isPublic ||\n      isFromPublicFolder ||\n      !!currentMemberInstance ||\n      window.history.length > 2)\n\n  return {\n    redirectLink,\n    redirectBack,\n    canRedirect\n  }\n}\n\nexport { useRedirectLink }\n"
  },
  {
    "path": "src/hooks/useRedirectLink.spec.jsx",
    "content": "import { renderHook, act } from '@testing-library/react'\nimport { useSearchParams, useNavigate } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\n\nimport * as helpers from './helpers'\nimport { useRedirectLink } from './useRedirectLink'\n\njest.mock('cozy-client', () => ({\n  ...jest.requireActual('cozy-client'),\n  useClient: jest.fn()\n}))\n\njest.mock('react-router-dom', () => ({\n  useSearchParams: jest.fn(),\n  useNavigate: jest.fn()\n}))\n\nconst originalHistory = window.history\n\ndescribe('useRedirectLink', () => {\n  const mockClient = {\n    collection: jest.fn().mockReturnValue({\n      fetchOwnPermissions: jest.fn().mockResolvedValue({\n        included: []\n      })\n    }),\n    getStackClient: jest.fn().mockReturnValue({\n      uri: 'https://my.cozy.cloud'\n    }),\n    getInstanceOptions: jest.fn().mockReturnValue({\n      subdomain: 'flat'\n    })\n  }\n  const mockNavigate = jest.fn()\n\n  beforeEach(() => {\n    useClient.mockReturnValue(mockClient)\n    useSearchParams.mockReturnValue([\n      new URLSearchParams('?redirectLink=drive%23%2Ffolder%2Fid123')\n    ])\n    useNavigate.mockReturnValue(mockNavigate)\n  })\n\n  afterEach(() => {\n    window.history = originalHistory\n    jest.clearAllMocks()\n  })\n\n  it('should redirect with navigate when is not public', async () => {\n    let render\n    await act(async () => {\n      render = renderHook(() => useRedirectLink())\n    })\n\n    render.result.current.redirectBack()\n\n    expect(mockNavigate).toHaveBeenCalledWith('/folder/id123')\n    expect(render.result.current.redirectLink).toBe('drive#/folder/id123')\n    expect(render.result.current.canRedirect).toBe(true)\n  })\n\n  it('should redirect with navigate when is from a public folder', async () => {\n    useSearchParams.mockReturnValue([\n      new URLSearchParams(\n        '?redirectLink=drive%23%2Ffolder%2Fid123&fromPublicFolder=true'\n      )\n    ])\n    let render\n    await act(async () => {\n      render = renderHook(() => useRedirectLink({ isPublic: true }))\n    })\n\n    render.result.current.redirectBack()\n\n    expect(mockNavigate).toHaveBeenCalledWith('/folder/id123')\n    expect(render.result.current.redirectLink).toBe('drive#/folder/id123')\n    expect(render.result.current.canRedirect).toBe(true)\n  })\n\n  it('should redirect with window.location in public when instance is known', async () => {\n    const spyChangeLocation = jest\n      .spyOn(helpers, 'changeLocation')\n      .mockImplementationOnce(() => {})\n    mockClient.collection().fetchOwnPermissions.mockResolvedValueOnce({\n      included: [\n        {\n          attributes: {\n            instance: 'https://other.cozy.cloud'\n          }\n        }\n      ]\n    })\n    useSearchParams.mockReturnValue([\n      new URLSearchParams('?redirectLink=drive%23%2Ffolder%2Fid123')\n    ])\n    let render\n    await act(async () => {\n      render = renderHook(() => useRedirectLink({ isPublic: true }))\n    })\n\n    render.result.current.redirectBack()\n\n    expect(mockNavigate).toHaveBeenCalledTimes(0)\n    expect(spyChangeLocation).toHaveBeenCalledWith(\n      'https://other-drive.cozy.cloud/#/folder/id123'\n    )\n    expect(render.result.current.redirectLink).toBe('drive#/folder/id123')\n    expect(render.result.current.canRedirect).toBe(true)\n  })\n\n  it('should redirect with navigate(-2) in public when the instance is unknown', async () => {\n    delete window.history\n    window.history = Object.defineProperties(\n      {},\n      {\n        ...Object.getOwnPropertyDescriptors(originalHistory),\n        length: {\n          configurable: true,\n          value: 3\n        }\n      }\n    )\n    mockClient.collection().fetchOwnPermissions.mockResolvedValueOnce({\n      included: [\n        {\n          attributes: {}\n        }\n      ]\n    })\n    useSearchParams.mockReturnValue([\n      new URLSearchParams('?redirectLink=drive%23%2Ffolder%2Fid123')\n    ])\n    let render\n    await act(async () => {\n      render = renderHook(() => useRedirectLink({ isPublic: true }))\n    })\n\n    render.result.current.redirectBack()\n\n    expect(mockNavigate).toHaveBeenCalledWith(-2)\n    expect(render.result.current.redirectLink).toBe('drive#/folder/id123')\n    expect(render.result.current.canRedirect).toBe(true)\n  })\n\n  it('should do nothing when the instance is unknown and the page is opened in new tab', async () => {\n    mockClient.collection().fetchOwnPermissions.mockResolvedValueOnce({\n      included: [\n        {\n          attributes: {}\n        }\n      ]\n    })\n    useSearchParams.mockReturnValue([\n      new URLSearchParams('?redirectLink=drive%23%2Ffolder%2Fid123')\n    ])\n    let render\n    await act(async () => {\n      render = renderHook(() => useRedirectLink({ isPublic: true }))\n    })\n\n    render.result.current.redirectBack()\n\n    expect(mockNavigate).toHaveBeenCalledTimes(0)\n    expect(render.result.current.redirectLink).toBe('drive#/folder/id123')\n    expect(render.result.current.canRedirect).toBe(false)\n  })\n})\n"
  },
  {
    "path": "src/hooks/useShiftSelection/helpers.spec.ts",
    "content": "import { IOCozyFile } from 'cozy-client/types/types'\n\nimport {\n  handleShiftArrow,\n  handleShiftClick,\n  FORWARD_DIRECTION,\n  BACKWARD_DIRECTION,\n  HandleShiftArrowParams,\n  HandleShiftClickParams\n} from './helpers'\n\nimport { SelectedItems } from '@/modules/selection/types'\n\nconst createMockFile = (id: string, name = `file-${id}`): IOCozyFile =>\n  ({\n    _id: id,\n    _type: 'io.cozy.files',\n    name,\n    type: 'file',\n    dir_id: 'root',\n    created_at: '2023-01-01T00:00:00.000Z',\n    updated_at: '2023-01-01T00:00:00.000Z',\n    size: 1000,\n    mime: 'text/plain',\n    class: 'text',\n    executable: false\n  }) as IOCozyFile\n\nconst mockFiles: IOCozyFile[] = [\n  createMockFile('1', 'file1.txt'),\n  createMockFile('2', 'file2.txt'),\n  createMockFile('3', 'file3.txt'),\n  createMockFile('4', 'file4.txt'),\n  createMockFile('5', 'file5.txt')\n]\n\ndescribe('handleShiftArrow', () => {\n  let mockIsItemSelected: jest.Mock\n\n  beforeEach(() => {\n    mockIsItemSelected = jest.fn()\n  })\n\n  afterEach(() => {\n    jest.clearAllMocks()\n  })\n\n  describe('when no items are selected', () => {\n    it('should select the first item when moving forward', () => {\n      const params: HandleShiftArrowParams = {\n        direction: FORWARD_DIRECTION,\n        items: mockFiles,\n        selectedItems: {},\n        lastInteractedIdx: 0,\n        isItemSelected: mockIsItemSelected\n      }\n\n      const result = handleShiftArrow(params)\n\n      expect(result).toEqual({\n        newSelectedItems: { '1': mockFiles[0] },\n        lastInteractedItemId: '1'\n      })\n    })\n\n    it('should select the last item when moving backward', () => {\n      const params: HandleShiftArrowParams = {\n        direction: BACKWARD_DIRECTION,\n        items: mockFiles,\n        selectedItems: {},\n        lastInteractedIdx: 0,\n        isItemSelected: mockIsItemSelected\n      }\n\n      const result = handleShiftArrow(params)\n\n      expect(result).toEqual({\n        newSelectedItems: { '5': mockFiles[4] },\n        lastInteractedItemId: '5'\n      })\n    })\n  })\n\n  describe('when items are already selected', () => {\n    it('should extend selection forward when moving from selected to unselected item', () => {\n      const selectedItems: SelectedItems = { '2': mockFiles[1] }\n      mockIsItemSelected.mockImplementation((id: string) => {\n        if (id === '2') return true // Previous item is selected\n        if (id === '3') return false // Current item is not selected\n        return false\n      })\n\n      const params: HandleShiftArrowParams = {\n        direction: FORWARD_DIRECTION,\n        items: mockFiles,\n        selectedItems,\n        lastInteractedIdx: 1,\n        isItemSelected: mockIsItemSelected\n      }\n\n      const result = handleShiftArrow(params)\n\n      expect(result.newSelectedItems).toEqual({\n        '2': mockFiles[1],\n        '3': mockFiles[2]\n      })\n      expect(result.lastInteractedItemId).toBe('3')\n    })\n\n    it('should contract selection when moving from selected to selected item', () => {\n      const selectedItems: SelectedItems = {\n        '1': mockFiles[0],\n        '2': mockFiles[1],\n        '3': mockFiles[2]\n      }\n      mockIsItemSelected.mockImplementation((id: string) => {\n        return ['1', '2', '3'].includes(id)\n      })\n\n      const params: HandleShiftArrowParams = {\n        direction: BACKWARD_DIRECTION,\n        items: mockFiles,\n        selectedItems,\n        lastInteractedIdx: 2,\n        isItemSelected: mockIsItemSelected\n      }\n\n      const result = handleShiftArrow(params)\n\n      expect(result.newSelectedItems).toEqual({\n        '1': mockFiles[0],\n        '2': mockFiles[1]\n      })\n      expect(result.lastInteractedItemId).toBe('2')\n    })\n\n    it('should handle boundary conditions at the start of the list', () => {\n      const selectedItems: SelectedItems = { '1': mockFiles[0] }\n      mockIsItemSelected.mockImplementation((id: string) => id === '1')\n\n      const params: HandleShiftArrowParams = {\n        direction: BACKWARD_DIRECTION,\n        items: mockFiles,\n        selectedItems,\n        lastInteractedIdx: 0,\n        isItemSelected: mockIsItemSelected\n      }\n\n      const result = handleShiftArrow(params)\n\n      expect(result.newSelectedItems).toEqual({})\n      expect(result.lastInteractedItemId).toBe('1')\n    })\n\n    it('should handle boundary conditions at the end of the list', () => {\n      const selectedItems: SelectedItems = { '5': mockFiles[4] }\n      mockIsItemSelected.mockImplementation((id: string) => id === '5')\n\n      const params: HandleShiftArrowParams = {\n        direction: FORWARD_DIRECTION,\n        items: mockFiles,\n        selectedItems,\n        lastInteractedIdx: 4,\n        isItemSelected: mockIsItemSelected\n      }\n\n      const result = handleShiftArrow(params)\n\n      expect(result.newSelectedItems).toEqual({})\n      expect(result.lastInteractedItemId).toBe('5')\n    })\n  })\n})\n\ndescribe('handleShiftClick', () => {\n  beforeEach(() => {\n    jest.clearAllMocks()\n  })\n\n  describe('range selection behavior', () => {\n    it('should select all items in range when end item is not selected', () => {\n      const selectedItems: SelectedItems = {}\n\n      const params: HandleShiftClickParams = {\n        startIdx: 1,\n        endIdx: 3,\n        selectedItems,\n        items: mockFiles\n      }\n\n      const result = handleShiftClick(params)\n\n      expect(result).toEqual({\n        newSelectedItems: {\n          '2': mockFiles[1],\n          '3': mockFiles[2],\n          '4': mockFiles[3]\n        },\n        lastInteractedItemId: '4'\n      })\n    })\n\n    it('should deselect all items in range when end item is selected', () => {\n      const selectedItems: SelectedItems = {\n        '1': mockFiles[0],\n        '2': mockFiles[1],\n        '3': mockFiles[2],\n        '4': mockFiles[3],\n        '5': mockFiles[4]\n      }\n\n      const params: HandleShiftClickParams = {\n        startIdx: 1,\n        endIdx: 3,\n        selectedItems,\n        items: mockFiles\n      }\n\n      const result = handleShiftClick(params)\n\n      expect(result).toEqual({\n        newSelectedItems: {\n          '1': mockFiles[0],\n          '5': mockFiles[4]\n        },\n        lastInteractedItemId: '4'\n      })\n    })\n\n    it('should handle reverse range selection (endIdx < startIdx)', () => {\n      const selectedItems: SelectedItems = {}\n\n      const params: HandleShiftClickParams = {\n        startIdx: 3,\n        endIdx: 1,\n        selectedItems,\n        items: mockFiles\n      }\n\n      const result = handleShiftClick(params)\n\n      expect(result).toEqual({\n        newSelectedItems: {\n          '2': mockFiles[1],\n          '3': mockFiles[2],\n          '4': mockFiles[3]\n        },\n        lastInteractedItemId: '2'\n      })\n    })\n\n    it('should handle single item selection (startIdx === endIdx)', () => {\n      const selectedItems: SelectedItems = {}\n\n      const params: HandleShiftClickParams = {\n        startIdx: 2,\n        endIdx: 2,\n        selectedItems,\n        items: mockFiles\n      }\n\n      const result = handleShiftClick(params)\n\n      expect(result).toEqual({\n        newSelectedItems: {\n          '3': mockFiles[2]\n        },\n        lastInteractedItemId: '3'\n      })\n    })\n  })\n\n  describe('boundary conditions', () => {\n    it('should handle selection at the beginning of the list', () => {\n      const selectedItems: SelectedItems = {}\n\n      const params: HandleShiftClickParams = {\n        startIdx: 0,\n        endIdx: 2,\n        selectedItems,\n        items: mockFiles\n      }\n\n      const result = handleShiftClick(params)\n\n      expect(result).toEqual({\n        newSelectedItems: {\n          '1': mockFiles[0],\n          '2': mockFiles[1],\n          '3': mockFiles[2]\n        },\n        lastInteractedItemId: '3'\n      })\n    })\n\n    it('should handle selection at the end of the list', () => {\n      const selectedItems: SelectedItems = {}\n\n      const params: HandleShiftClickParams = {\n        startIdx: 2,\n        endIdx: 4,\n        selectedItems,\n        items: mockFiles\n      }\n\n      const result = handleShiftClick(params)\n\n      expect(result).toEqual({\n        newSelectedItems: {\n          '3': mockFiles[2],\n          '4': mockFiles[3],\n          '5': mockFiles[4]\n        },\n        lastInteractedItemId: '5'\n      })\n    })\n\n    it('should handle full list selection', () => {\n      const selectedItems: SelectedItems = {}\n\n      const params: HandleShiftClickParams = {\n        startIdx: 0,\n        endIdx: 4,\n        selectedItems,\n        items: mockFiles\n      }\n\n      const result = handleShiftClick(params)\n\n      expect(result).toEqual({\n        newSelectedItems: {\n          '1': mockFiles[0],\n          '2': mockFiles[1],\n          '3': mockFiles[2],\n          '4': mockFiles[3],\n          '5': mockFiles[4]\n        },\n        lastInteractedItemId: '5'\n      })\n    })\n  })\n\n  describe('mixed selection scenarios', () => {\n    it('should handle partial existing selection', () => {\n      const selectedItems: SelectedItems = {\n        '1': mockFiles[0],\n        '5': mockFiles[4]\n      }\n\n      const params: HandleShiftClickParams = {\n        startIdx: 1,\n        endIdx: 3,\n        selectedItems,\n        items: mockFiles\n      }\n\n      const result = handleShiftClick(params)\n\n      expect(result).toEqual({\n        newSelectedItems: {\n          '1': mockFiles[0],\n          '2': mockFiles[1],\n          '3': mockFiles[2],\n          '4': mockFiles[3],\n          '5': mockFiles[4]\n        },\n        lastInteractedItemId: '4'\n      })\n    })\n\n    it('should preserve items outside the range when deselecting', () => {\n      const selectedItems: SelectedItems = {\n        '1': mockFiles[0],\n        '2': mockFiles[1],\n        '3': mockFiles[2],\n        '4': mockFiles[3],\n        '5': mockFiles[4]\n      }\n\n      const params: HandleShiftClickParams = {\n        startIdx: 1,\n        endIdx: 2,\n        selectedItems,\n        items: mockFiles\n      }\n\n      const result = handleShiftClick(params)\n\n      expect(result).toEqual({\n        newSelectedItems: {\n          '1': mockFiles[0],\n          '4': mockFiles[3],\n          '5': mockFiles[4]\n        },\n        lastInteractedItemId: '3'\n      })\n    })\n  })\n})\n"
  },
  {
    "path": "src/hooks/useShiftSelection/helpers.ts",
    "content": "import cloneDeep from 'lodash/cloneDeep'\n\nimport { IOCozyFile } from 'cozy-client/types/types'\n\nimport type { SelectedItems } from '@/modules/selection/types'\n\nexport const FORWARD_DIRECTION = 1 as const\nexport const BACKWARD_DIRECTION = -1 as const\n\ninterface HandleShiftSelectionResponse {\n  newSelectedItems: SelectedItems\n  lastInteractedItemId: string\n}\n\ninterface FindNextBoundaryIndexParams {\n  items: IOCozyFile[]\n  startIdx: number\n  direction: typeof FORWARD_DIRECTION | typeof BACKWARD_DIRECTION\n  isMovingToSelect: boolean\n  isReturnCurrent: boolean\n  isItemSelected: (id: string) => boolean\n}\n\ninterface ToggleSelectionParams {\n  items: IOCozyFile[]\n  selectedItems: SelectedItems\n  currentIdx: number\n  lastInteractedIdx: number\n  isMovingToSelect: boolean\n  isItemSelected: (id: string) => boolean\n}\n\nexport interface HandleShiftArrowParams {\n  direction: typeof FORWARD_DIRECTION | typeof BACKWARD_DIRECTION\n  items: IOCozyFile[]\n  selectedItems: SelectedItems\n  lastInteractedIdx: number\n  isItemSelected: (id: string) => boolean\n}\n\nexport interface HandleShiftClickParams {\n  startIdx: number\n  endIdx: number\n  selectedItems: SelectedItems\n  items: IOCozyFile[]\n}\n\n/**\n * Clamps an index value to be within valid array bounds.\n * @param {number} maxLength The maximum length of the array\n * @param {number} index The index to clamp\n *\n * @returns {number} The clamped index value between 0 and maxLength-1\n */\nconst clamp = (maxLength: number, index: number): number =>\n  Math.max(0, Math.min(maxLength - 1, index))\n\n/**\n * Find the next index (in given direction) where selection state flips.\n * This defines the next \"boundary\" for select/deselect operations.\n * Used to determine where to stop when selecting or deselecting.\n *\n * @param {FindNextBoundaryIndexParams} params The parameters object\n * @param {IOCozyFile[]} params.items Array of all available items\n * @param {number} params.startIdx Starting index to search from\n * @param {number} params.direction Direction to search (1 for forward, -1 for backward)\n * @param {boolean} params.isMovingToSelect Whether we're moving to select or deselect items\n * @param {boolean} params.isReturnCurrent Determine if we have to find next index or not\n * @param {function} params.isItemSelected Function to check if an item is selected\n *\n * @returns {number} The index of the next boundary where selection state changes\n */\nconst findNextBoundaryIndex = ({\n  items,\n  startIdx,\n  direction,\n  isMovingToSelect,\n  isReturnCurrent,\n  isItemSelected\n}: FindNextBoundaryIndexParams): number => {\n  if (isReturnCurrent) return startIdx\n\n  let idx = startIdx + direction\n\n  while (\n    idx >= 0 &&\n    idx < items.length &&\n    isMovingToSelect === isItemSelected(items[idx]?._id)\n  ) {\n    idx += direction\n  }\n\n  return clamp(items.length, idx - direction)\n}\n\n/**\n * Toggles the selection state of items based on keyboard navigation.\n * Handles the complex logic of selection or deselecting selections during Shift+Arrow operations.\n *\n * @param {ToggleSelectionParams} params The parameters object\n * @param {IOCozyFile[]} params.items Array of all available items\n * @param {SelectedItems} params.selectedItems Current selected items object\n * @param {number} params.currentIdx Current index being navigated to\n * @param {number} params.lastInteractedIdx Index of the last interacted item\n * @param {boolean} params.isMovingToSelect Whether we're moving to select or deselect items\n * @param {function} params.isItemSelected Function to check if an item is selected\n *\n * @returns {SelectedItems}\n */\nconst toggleSelection = ({\n  items,\n  selectedItems,\n  currentIdx,\n  lastInteractedIdx,\n  isMovingToSelect,\n  isItemSelected\n}: ToggleSelectionParams): SelectedItems => {\n  // Identify which item to modify (depends on selection direction)\n  const targetItem = isMovingToSelect\n    ? items[currentIdx]\n    : isItemSelected(items[lastInteractedIdx]._id)\n      ? items[lastInteractedIdx]\n      : items[currentIdx]\n\n  return Object.entries(selectedItems).reduce<SelectedItems>(\n    (acc, [key, value]) => {\n      if (isMovingToSelect) {\n        acc[key] = value\n        acc[targetItem._id] = targetItem\n      } else {\n        if (key !== targetItem._id) {\n          acc[key] = value\n        }\n      }\n      return acc\n    },\n    {}\n  )\n}\n\n/**\n * Handle Shift + Arrow keyboard selection.\n * - If no items are selected, selects the first/last item based on direction\n * - Return selected items based on selection state\n * - Return focus position for continued navigation\n *\n * @param {HandleShiftArrowParams} params The parameters object\n * @param {number} params.direction Direction of arrow key (FORWARD_DIRECTION or BACKWARD_DIRECTION)\n * @param {IOCozyFile[]} [params.items] Array of all available items (defaults to empty array)\n * @param {SelectedItems} [params.selectedItems] Current selected items object (defaults to empty object)\n * @param {number} params.lastInteractedIdx Index of the last interacted item\n * @param {function} params.isItemSelected Function to check if an item is selected by _id\n *\n * @returns {HandleShiftSelectionResponse}\n */\nexport const handleShiftArrow = ({\n  direction,\n  items,\n  selectedItems = {},\n  lastInteractedIdx,\n  isItemSelected\n}: HandleShiftArrowParams): HandleShiftSelectionResponse => {\n  if (Object.keys(selectedItems).length === 0) {\n    const autoSelectedItem =\n      direction === FORWARD_DIRECTION ? items[0] : items[items.length - 1]\n    return {\n      newSelectedItems: {\n        [autoSelectedItem._id]: autoSelectedItem\n      },\n      lastInteractedItemId: autoSelectedItem._id\n    }\n  }\n\n  const nextIdx = lastInteractedIdx + direction\n  const currentIdx = clamp(items.length, nextIdx)\n\n  const prevSelected = isItemSelected(items[lastInteractedIdx]?._id)\n  const currSelected = isItemSelected(items[currentIdx]?._id)\n  const isMovingToSelect = prevSelected && !currSelected\n\n  const newSelectedItems = toggleSelection({\n    items,\n    selectedItems,\n    currentIdx,\n    lastInteractedIdx,\n    isMovingToSelect,\n    isItemSelected\n  })\n\n  // Updates focus position for continued navigation\n  const finalIdx = findNextBoundaryIndex({\n    items,\n    startIdx: currentIdx,\n    direction,\n    isMovingToSelect,\n    isItemSelected,\n    isReturnCurrent: Object.keys(newSelectedItems).length <= 1\n  })\n\n  return {\n    newSelectedItems,\n    lastInteractedItemId: items[finalIdx]._id\n  }\n}\n\n/**\n * Handle Shift + Click range selection.\n * - Selects all items in range if end item is not selected\n * - Deselects all items in range if end item is already selected\n * - Handles reverse ranges (endIdx < startIdx) automatically\n * - Return the last interacted item to the clicked item and new selections\n *\n * @param {HandleShiftClickParams} params The parameters object\n * @param {number} params.startIdx Starting index of the selection range (last interacted item)\n * @param {number} params.endIdx Ending index of the selection range (last clicked item)\n * @param {SelectedItems} params.selectedItems Current selected items object\n * @param {IOCozyFile[]} params.items Array of all available items\n *\n * @returns {HandleShiftSelectionResponse}\n */\nexport const handleShiftClick = ({\n  startIdx,\n  endIdx,\n  selectedItems,\n  items\n}: HandleShiftClickParams): HandleShiftSelectionResponse => {\n  const endItem = items[endIdx]\n  const isMovingToSelect = !Object.hasOwn(selectedItems, endItem._id)\n  const start = Math.min(startIdx, endIdx)\n  const end = Math.max(startIdx, endIdx)\n\n  const newSelectedItems = Array.from(\n    { length: end - start + 1 },\n    (_, i) => start + i\n  ).reduce((acc, i) => {\n    const item = items[i]\n    if (isMovingToSelect) {\n      acc[item._id] = item\n    } else {\n      const { [item._id]: _, ...rest } = acc\n      return rest\n    }\n    return acc\n  }, cloneDeep(selectedItems))\n\n  return {\n    newSelectedItems,\n    lastInteractedItemId: items[endIdx]._id\n  }\n}\n"
  },
  {
    "path": "src/hooks/useShiftSelection/index.spec.tsx",
    "content": "import { renderHook, act } from '@testing-library/react'\nimport { RefObject } from 'react'\n\nimport { IOCozyFile } from 'cozy-client/types/types'\n\nimport * as helpers from './helpers'\nimport { useShiftSelection } from './index'\n\njest.mock('cozy-ui/transpiled/react/providers/Breakpoints', () => ({\n  __esModule: true,\n  default: (): { isMobile: boolean } => ({ isMobile: false })\n}))\n\njest.mock('@/modules/selection/SelectionProvider', () => ({\n  useSelectionContext: jest.fn()\n}))\n\njest.mock('./helpers', () => ({\n  handleShiftClick: jest.fn().mockReturnValue({\n    newSelectedItems: {},\n    lastInteractedItemId: '1'\n  }),\n  handleShiftArrow: jest.fn().mockReturnValue({\n    newSelectedItems: {},\n    lastInteractedItemId: '1'\n  }),\n  FORWARD_DIRECTION: 1,\n  BACKWARD_DIRECTION: -1\n}))\n\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nconst mockUseSelectionContext = useSelectionContext as jest.Mock\n\n// Get references to mocked functions\nconst mockHandleShiftArrow = helpers.handleShiftArrow as jest.Mock\nconst mockHandleShiftClick = helpers.handleShiftClick as jest.Mock\n\nconst createMockFile = (id: string): IOCozyFile =>\n  ({\n    _id: id,\n    name: `file-${id}`,\n    type: 'file'\n  }) as IOCozyFile\n\nconst mockFiles = [\n  createMockFile('1'),\n  createMockFile('2'),\n  createMockFile('3')\n]\n\ndescribe('useShiftSelection', () => {\n  let mockSetSelectedItems: jest.Mock\n  let mockIsItemSelected: jest.Mock\n  let mockRef: RefObject<HTMLElement>\n  let mockElement: HTMLElement\n\n  beforeEach(() => {\n    mockSetSelectedItems = jest.fn()\n    mockIsItemSelected = jest.fn()\n\n    mockElement = {\n      focus: jest.fn(),\n      addEventListener: jest.fn(),\n      removeEventListener: jest.fn()\n    } as unknown as HTMLElement\n\n    mockRef = { current: mockElement }\n\n    mockUseSelectionContext.mockReturnValue({\n      selectedItems: [],\n      setSelectedItems: mockSetSelectedItems,\n      isItemSelected: mockIsItemSelected,\n      setIsSelectAll: jest.fn()\n    })\n\n    jest.clearAllMocks()\n  })\n\n  describe('initialization', () => {\n    it('should return correct interface', () => {\n      const { result } = renderHook(() =>\n        useShiftSelection({ items: mockFiles }, mockRef)\n      )\n\n      expect(result.current).toHaveProperty('setLastInteractedItem')\n      expect(result.current).toHaveProperty('onShiftClick')\n      expect(typeof result.current.onShiftClick).toBe('function')\n      expect(typeof result.current.setLastInteractedItem).toBe('function')\n    })\n  })\n\n  describe('keyboard event handling - list view', () => {\n    it('should call handleShiftArrow on Shift+ArrowDown in list view', () => {\n      renderHook(() =>\n        useShiftSelection({ items: mockFiles, viewType: 'list' }, mockRef)\n      )\n\n      const keydownHandler = (\n        (mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[]\n      )[1] as (event: KeyboardEvent) => void\n      const mockEvent = {\n        shiftKey: true,\n        key: 'ArrowDown',\n        preventDefault: jest.fn()\n      } as unknown as KeyboardEvent\n\n      act(() => {\n        keydownHandler(mockEvent)\n      })\n\n      expect(mockHandleShiftArrow).toHaveBeenCalledWith({\n        direction: 1,\n        items: mockFiles,\n        selectedItems: {},\n        lastInteractedIdx: 0,\n        isItemSelected: mockIsItemSelected\n      })\n    })\n\n    it('should call handleShiftArrow on Shift+ArrowUp in list view', () => {\n      renderHook(() =>\n        useShiftSelection({ items: mockFiles, viewType: 'list' }, mockRef)\n      )\n\n      const keydownHandler = (\n        (mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[]\n      )[1] as (event: KeyboardEvent) => void\n      const mockEvent = {\n        shiftKey: true,\n        key: 'ArrowUp',\n        preventDefault: jest.fn()\n      } as unknown as KeyboardEvent\n\n      act(() => {\n        keydownHandler(mockEvent)\n      })\n\n      expect(mockHandleShiftArrow).toHaveBeenCalledWith(\n        expect.objectContaining({ direction: -1 })\n      )\n    })\n  })\n\n  describe('keyboard event handling - grid view', () => {\n    it('should call handleShiftArrow on Shift+ArrowRight in grid view', () => {\n      renderHook(() =>\n        useShiftSelection({ items: mockFiles, viewType: 'grid' }, mockRef)\n      )\n\n      const keydownHandler = (\n        (mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[]\n      )[1] as (event: KeyboardEvent) => void\n      const mockEvent = {\n        shiftKey: true,\n        key: 'ArrowRight',\n        preventDefault: jest.fn()\n      } as unknown as KeyboardEvent\n\n      act(() => {\n        keydownHandler(mockEvent)\n      })\n\n      expect(mockHandleShiftArrow).toHaveBeenCalledWith(\n        expect.objectContaining({ direction: 1 })\n      )\n    })\n\n    it('should call handleShiftArrow on Shift+ArrowLeft in grid view', () => {\n      renderHook(() =>\n        useShiftSelection({ items: mockFiles, viewType: 'grid' }, mockRef)\n      )\n\n      const keydownHandler = (\n        (mockElement.addEventListener as jest.Mock).mock.calls[0] as unknown[]\n      )[1] as (event: KeyboardEvent) => void\n      const mockEvent = {\n        shiftKey: true,\n        key: 'ArrowLeft',\n        preventDefault: jest.fn()\n      } as unknown as KeyboardEvent\n\n      act(() => {\n        keydownHandler(mockEvent)\n      })\n\n      expect(mockHandleShiftArrow).toHaveBeenCalledWith(\n        expect.objectContaining({ direction: -1 })\n      )\n    })\n  })\n\n  describe('onShiftClick', () => {\n    it('should call handleShiftClick when shift key is pressed', () => {\n      const { result } = renderHook(() =>\n        useShiftSelection({ items: mockFiles }, mockRef)\n      )\n\n      const mockEvent = {\n        shiftKey: true,\n        stopPropagation: jest.fn()\n      } as unknown as KeyboardEvent\n\n      act(() => {\n        result.current.onShiftClick('2', mockEvent)\n      })\n\n      expect(mockHandleShiftClick).toHaveBeenCalledWith({\n        startIdx: 0,\n        endIdx: 1,\n        selectedItems: {},\n        items: mockFiles\n      })\n    })\n\n    it('should not call handleShiftClick when shift key is not pressed', () => {\n      const { result } = renderHook(() =>\n        useShiftSelection({ items: mockFiles }, mockRef)\n      )\n\n      const mockEvent = {\n        shiftKey: false,\n        stopPropagation: jest.fn()\n      } as unknown as KeyboardEvent\n\n      act(() => {\n        result.current.onShiftClick('2', mockEvent)\n      })\n\n      expect(mockHandleShiftClick).not.toHaveBeenCalled()\n    })\n  })\n})\n"
  },
  {
    "path": "src/hooks/useShiftSelection/index.tsx",
    "content": "/* eslint-disable react-hooks/refs */\nimport {\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n  RefObject\n} from 'react'\n\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport {\n  handleShiftClick,\n  handleShiftArrow,\n  BACKWARD_DIRECTION,\n  FORWARD_DIRECTION\n} from './helpers'\n\nimport { isEditableTarget } from '@/hooks/helpers'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport { SelectedItems } from '@/modules/selection/types'\n\ntype ViewType = 'list' | 'grid'\n\ninterface UseShiftSelectionParams {\n  items: IOCozyFile[]\n  viewType?: ViewType\n}\n\ninterface UseShiftSelectionReturn {\n  setLastInteractedItem: (id: string | null) => void\n  onShiftClick: (clickedItemId: string, event: KeyboardEvent) => void\n}\n\n/**\n * Custom hook that provides shift-based range selection functionality for file/folder lists.\n *\n * This hook enables users to:\n * - Select ranges of items using Shift+Click (from last interacted item to clicked item)\n * - Navigate and extend selection using Shift+Arrow keys (direction depends on viewType)\n *\n * @param {UseShiftSelectionParams} params - Configuration object containing items and view type\n * @param {IOCozyFile[]} params.items - Array of IOCozyFile objects to enable selection on\n * @param {ViewType} params.viewType - View type ('list' or 'grid') that determines keyboard navigation behavior\n * @param ref - React ref to the container element that should receive keyboard events\n *\n * @returns {UseShiftSelectionReturn}\n */\nconst useShiftSelection = (\n  { items, viewType = 'list' }: UseShiftSelectionParams,\n  ref: RefObject<HTMLElement>\n): UseShiftSelectionReturn => {\n  const { isMobile } = useBreakpoints()\n\n  const itemsRef = useRef<IOCozyFile[]>([])\n  itemsRef.current = useMemo(() => items, [items])\n\n  const { selectedItems, setSelectedItems, isItemSelected, setIsSelectAll } =\n    useSelectionContext()\n\n  const [lastInteractedItem, setLastInteractedItem] = useState<string | null>(\n    null\n  )\n\n  const lastInteractedIdx = useMemo(() => {\n    return lastInteractedItem\n      ? itemsRef.current.findIndex(item => item._id === lastInteractedItem)\n      : 0\n  }, [lastInteractedItem])\n\n  const selectedItemMap: SelectedItems = useMemo(() => {\n    return selectedItems.reduce<SelectedItems>(\n      (prev: SelectedItems, cur: IOCozyFile) => ({\n        ...prev,\n        [cur._id]: cur\n      }),\n      {}\n    )\n  }, [selectedItems])\n\n  /**\n   * Handles shift+click events for range selection.\n   *\n   * When shift key is held and an item is clicked, selects or deselects all items\n   * between the last interacted item and the clicked item (inclusive).\n   *\n   * @param {string} clickedItemId - ID of the item that was clicked\n   * @param {KeyboardEvent} event - The keyboard event (must have shiftKey = true)\n   */\n  const onShiftClick = useCallback(\n    (clickedItemId: string, event: KeyboardEvent) => {\n      if (!event.shiftKey) return\n\n      event.stopPropagation()\n\n      const endIdx = items.findIndex(item => item._id === clickedItemId)\n      const { newSelectedItems, lastInteractedItemId } = handleShiftClick({\n        startIdx: lastInteractedIdx,\n        endIdx,\n        selectedItems: selectedItemMap,\n        items\n      })\n\n      setSelectedItems(newSelectedItems)\n      setLastInteractedItem(lastInteractedItemId)\n      setIsSelectAll(\n        Object.keys(newSelectedItems).length === itemsRef.current.length\n      )\n    },\n    [\n      items,\n      lastInteractedIdx,\n      selectedItemMap,\n      setSelectedItems,\n      setIsSelectAll,\n      setLastInteractedItem\n    ]\n  )\n\n  /**\n   * Handles keyboard events for shift+arrow navigation.\n   *\n   * Listens for shift+arrow key combinations and extends/contracts selection\n   * based on the navigation direction. The specific arrow keys depend on viewType:\n   * - List view: ArrowUp (backward) / ArrowDown (forward)\n   * - Grid view: ArrowLeft (backward) / ArrowRight (forward)\n   *\n   * @param {KeyboardEvent} event - The keyboard event to handle\n   */\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent) => {\n      if (!event.shiftKey) return\n\n      const key = event.key\n      const isListKey =\n        viewType === 'list' && ['ArrowUp', 'ArrowDown'].includes(key)\n      const isGridKey =\n        viewType === 'grid' && ['ArrowLeft', 'ArrowRight'].includes(key)\n\n      if (!isListKey && !isGridKey) return\n\n      event.preventDefault()\n\n      const direction =\n        key === 'ArrowUp' || key === 'ArrowLeft'\n          ? BACKWARD_DIRECTION\n          : FORWARD_DIRECTION\n\n      const { newSelectedItems, lastInteractedItemId } = handleShiftArrow({\n        direction,\n        items: itemsRef.current,\n        selectedItems: selectedItemMap,\n        lastInteractedIdx,\n        isItemSelected\n      })\n\n      setSelectedItems(newSelectedItems)\n      setLastInteractedItem(lastInteractedItemId)\n      setIsSelectAll(selectedItems.length === itemsRef.current.length)\n    },\n    [\n      viewType,\n      selectedItemMap,\n      lastInteractedIdx,\n      selectedItems.length,\n      setSelectedItems,\n      isItemSelected,\n      setIsSelectAll,\n      setLastInteractedItem\n    ]\n  )\n\n  /**\n   * Sets up keyboard event listeners on the container element.\n   *\n   * - Focuses the container to ensure it can receive keyboard events\n   * - Adds keydown event listener for shift+arrow navigation\n   * - Skips setup on mobile devices or when no items/container available\n   */\n  useEffect(() => {\n    if (isMobile || !itemsRef.current.length || !ref.current) return\n\n    const container = ref.current\n    if (!isEditableTarget(document.activeElement)) {\n      container.focus()\n    }\n\n    container.addEventListener('keydown', handleKeyDown)\n    return (): void => {\n      container.removeEventListener('keydown', handleKeyDown)\n    }\n  }, [isMobile, ref, handleKeyDown])\n\n  return {\n    setLastInteractedItem,\n    onShiftClick\n  }\n}\n\nexport { useShiftSelection }\n"
  },
  {
    "path": "src/hooks/useTransformFolderListHasSharedDriveShortcuts/index.spec.jsx",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { useTransformFolderListHasSharedDriveShortcuts } from './index'\n\nimport { SHARED_DRIVES_DIR_ID } from '@/constants/config'\n\njest.mock('cozy-sharing', () => ({\n  useSharingContext: jest.fn()\n}))\n\njest.mock('@/modules/nextcloud/helpers', () => ({\n  isNextcloudShortcut: jest.fn()\n}))\n\njest.mock('@/modules/shareddrives/hooks/useSharedDrives', () => ({\n  useSharedDrives: jest.fn()\n}))\n\nconst mockUseSharingContext = require('cozy-sharing').useSharingContext\n\nconst mockIsNextcloudShortcut =\n  require('@/modules/nextcloud/helpers').isNextcloudShortcut\nconst mockUseSharedDrives =\n  require('@/modules/shareddrives/hooks/useSharedDrives').useSharedDrives\n\ndescribe('useTransformFolderListHasSharedDriveShortcuts', () => {\n  beforeEach(() => {\n    jest.resetAllMocks()\n\n    mockUseSharingContext.mockReturnValue({\n      isOwner: jest.fn(() => false)\n    })\n\n    mockUseSharedDrives.mockReturnValue({\n      sharedDrives: []\n    })\n\n    mockIsNextcloudShortcut.mockReturnValue(false)\n  })\n\n  describe('transformedSharedDrives', () => {\n    it('should transform shared drives into directory-like objects', () => {\n      const mockSharedDrives = [\n        {\n          id: 'sharing-1',\n          rules: [\n            {\n              values: ['folder-1'],\n              title: 'Shared Drive 1'\n            }\n          ]\n        }\n      ]\n\n      mockUseSharedDrives.mockReturnValue({\n        sharedDrives: mockSharedDrives\n      })\n\n      const { result } = renderHook(() =>\n        useTransformFolderListHasSharedDriveShortcuts([])\n      )\n\n      expect(result.current.sharedDrives).toHaveLength(1)\n      expect(result.current.sharedDrives[0]).toMatchObject({\n        _id: 'folder-1',\n        id: 'folder-1',\n        _type: 'io.cozy.files',\n        type: 'directory',\n        name: 'Shared Drive 1',\n        dir_id: SHARED_DRIVES_DIR_ID,\n        driveId: 'sharing-1',\n        path: '/Drives/Shared Drive 1'\n      })\n    })\n\n    it('should return existing file when user is owner', () => {\n      const mockSharedDrives = [\n        {\n          id: 'sharing-1',\n          rules: [\n            {\n              values: ['folder-1'],\n              title: 'Shared Drive 1'\n            }\n          ]\n        }\n      ]\n\n      const mockFolderList = [\n        {\n          _id: 'file-1',\n          id: 'file-1',\n          name: 'Existing File',\n          relationships: {\n            referenced_by: {\n              data: [{ id: 'sharing-1' }]\n            }\n          }\n        }\n      ]\n\n      mockUseSharedDrives.mockReturnValue({\n        sharedDrives: mockSharedDrives\n      })\n\n      mockUseSharingContext.mockReturnValue({\n        isOwner: jest.fn(() => true)\n      })\n\n      const { result } = renderHook(() =>\n        useTransformFolderListHasSharedDriveShortcuts(mockFolderList)\n      )\n\n      expect(result.current.sharedDrives).toHaveLength(1)\n      expect(result.current.sharedDrives[0]).toMatchObject({\n        _id: 'file-1',\n        id: 'file-1',\n        name: 'Existing File'\n      })\n    })\n\n    it('should filter out nextcloud shortcuts', () => {\n      const mockSharedDrives = [\n        {\n          id: 'sharing-1',\n          rules: [\n            {\n              values: ['folder-1'],\n              title: 'Regular Drive'\n            }\n          ]\n        },\n        {\n          id: 'sharing-2',\n          rules: [\n            {\n              values: ['folder-2'],\n              title: 'Nextcloud Drive'\n            }\n          ]\n        }\n      ]\n\n      mockUseSharedDrives.mockReturnValue({\n        sharedDrives: mockSharedDrives\n      })\n\n      // Mock first drive as regular, second as nextcloud\n      mockIsNextcloudShortcut\n        .mockReturnValueOnce(false)\n        .mockReturnValueOnce(true)\n\n      const { result } = renderHook(() =>\n        useTransformFolderListHasSharedDriveShortcuts([])\n      )\n\n      expect(result.current.sharedDrives).toHaveLength(1)\n      expect(result.current.sharedDrives[0].name).toBe('Regular Drive')\n    })\n  })\n\n  describe('nonSharedDriveList', () => {\n    it('should filter out shared drives from folder list', () => {\n      const mockFolderList = [\n        {\n          _id: 'file-1',\n          name: 'Regular File',\n          dir_id: 'regular-folder'\n        },\n        {\n          _id: 'file-2',\n          name: 'Shared Drive File',\n          dir_id: SHARED_DRIVES_DIR_ID\n        }\n      ]\n\n      const { result } = renderHook(() =>\n        useTransformFolderListHasSharedDriveShortcuts(mockFolderList)\n      )\n\n      expect(result.current.nonSharedDriveList).toHaveLength(1)\n      expect(result.current.nonSharedDriveList[0].name).toBe('Regular File')\n    })\n\n    it('should include nextcloud shortcuts when showNextcloudFolder is true', () => {\n      const mockFolderList = [\n        {\n          _id: 'file-1',\n          name: 'Regular File',\n          dir_id: 'regular-folder'\n        },\n        {\n          _id: 'file-2',\n          name: 'Nextcloud File',\n          dir_id: 'regular-folder'\n        }\n      ]\n\n      mockIsNextcloudShortcut\n        .mockReturnValueOnce(false)\n        .mockReturnValueOnce(true)\n\n      const { result } = renderHook(() =>\n        useTransformFolderListHasSharedDriveShortcuts(mockFolderList, true)\n      )\n\n      expect(result.current.nonSharedDriveList).toHaveLength(2)\n      expect(result.current.nonSharedDriveList.map(f => f.name)).toEqual([\n        'Regular File',\n        'Nextcloud File'\n      ])\n    })\n\n    it('should exclude files referenced by shared drives to avoid duplicates', () => {\n      const mockSharedDrives = [\n        {\n          id: 'sharing-1',\n          rules: [\n            {\n              values: ['folder-1'],\n              title: 'Shared Drive 1'\n            }\n          ]\n        }\n      ]\n\n      const mockFolderList = [\n        {\n          _id: 'file-1',\n          id: 'file-1',\n          name: 'Regular File',\n          dir_id: 'regular-folder',\n          relationships: {}\n        },\n        {\n          _id: 'file-2',\n          id: 'file-2',\n          name: 'Shared Drive File',\n          dir_id: 'regular-folder',\n          relationships: {\n            referenced_by: {\n              data: [{ id: 'sharing-1' }]\n            }\n          }\n        }\n      ]\n\n      mockUseSharedDrives.mockReturnValue({\n        sharedDrives: mockSharedDrives,\n        isLoaded: true\n      })\n\n      mockUseSharingContext.mockReturnValue({\n        isOwner: jest.fn(id => id === 'file-2')\n      })\n\n      const { result } = renderHook(() =>\n        useTransformFolderListHasSharedDriveShortcuts(mockFolderList, true)\n      )\n\n      // The file referenced by the shared drive should not be in nonSharedDriveList\n      expect(result.current.nonSharedDriveList).toHaveLength(1)\n      expect(result.current.nonSharedDriveList[0].name).toBe('Regular File')\n\n      // But it should be in transformedSharedDrives\n      expect(result.current.sharedDrives).toHaveLength(1)\n      expect(result.current.sharedDrives[0].name).toBe('Shared Drive File')\n    })\n  })\n})\n"
  },
  {
    "path": "src/hooks/useTransformFolderListHasSharedDriveShortcuts/index.tsx",
    "content": "import { useMemo } from 'react'\n\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport { useSharingContext } from 'cozy-sharing'\n\nimport { SHARED_DRIVES_DIR_ID, TRASH_DIR_PATH } from '@/constants/config'\nimport { isNextcloudShortcut } from '@/modules/nextcloud/helpers'\nimport { useSharedDrives } from '@/modules/shareddrives/hooks/useSharedDrives'\n\ninterface SharingRule {\n  values?: string[]\n  title?: string\n}\n\ninterface SharedDrive {\n  id: string\n  rules: SharingRule[]\n}\n\ninterface TransformedSharedDrive extends IOCozyFile {\n  driveId: string\n}\n\ninterface UseTransformFolderListReturn {\n  sharedDrives: TransformedSharedDrive[]\n  nonSharedDriveList: IOCozyFile[]\n  sharedDrivesLoaded: boolean\n}\n\nconst useTransformFolderListHasSharedDriveShortcuts = (\n  folderList?: IOCozyFile[],\n  showNextcloudFolder = false\n): UseTransformFolderListReturn => {\n  const { isOwner } = useSharingContext() as unknown as {\n    isOwner: (fileId: string) => boolean\n  }\n\n  const { sharedDrives, isLoaded: sharedDrivesLoaded } = useSharedDrives()\n\n  /**\n   * Filter out Nextcloud shortcuts from shared drives.\n   */\n  const filteredSharedDrives = useMemo(\n    () =>\n      sharedDrives.filter(\n        sharing => !isNextcloudShortcut(sharing as unknown as IOCozyFile)\n      ),\n    [sharedDrives]\n  )\n\n  /**\n   * The recipient's shared drives are displayed as shortcuts which cannot accessible\n   * In some cases (like open shared drive from folder picker or sharing section...),\n   *  we want to access to shared drives as directories for both owner and recipient\n   * The codes below help us to transform the shared drives shortcuts into directory-like objects\n   */\n  const transformedSharedDrives = useMemo(\n    () =>\n      filteredSharedDrives.map((sharing: SharedDrive) => {\n        const [rootFolderId, driveName] = [\n          sharing.rules[0]?.values?.[0],\n          sharing.rules[0]?.title ?? ''\n        ]\n\n        const fileInSharingSection = folderList?.find(item =>\n          item.relationships?.referenced_by?.data?.some(\n            ref => ref.id === sharing.id\n          )\n        )\n\n        if (fileInSharingSection && isOwner(fileInSharingSection.id ?? ''))\n          return fileInSharingSection as TransformedSharedDrive\n\n        const directoryData = {\n          type: 'directory' as const,\n          name: driveName,\n          dir_id: SHARED_DRIVES_DIR_ID,\n          driveId: sharing.id\n        }\n\n        return {\n          ...fileInSharingSection,\n          _id: rootFolderId,\n          id: rootFolderId,\n          _type: 'io.cozy.files' as const,\n          path: `/Drives/${driveName}`,\n          ...directoryData,\n          attributes: directoryData\n        } as TransformedSharedDrive\n      }),\n    [filteredSharedDrives, folderList, isOwner]\n  )\n\n  /**\n   * Create a Set of shared drive IDs for efficient lookup\n   */\n  const sharedDriveIds = useMemo(\n    () => new Set(filteredSharedDrives.map((drive: SharedDrive) => drive.id)),\n    [filteredSharedDrives]\n  )\n\n  /**\n   * Exclude shared drives from the folderList,\n   * since it will be replaced with transformed ones above.\n   * Also exclude files that are referenced by a shared drive to avoid duplicates.\n   */\n  const nonSharedDriveList = useMemo(\n    () =>\n      folderList?.filter(item => {\n        const referencedByData = item.relationships?.referenced_by?.data ?? []\n        const isReferencedBySharedDrive = referencedByData.some(ref =>\n          sharedDriveIds.has(ref.id)\n        )\n        return (\n          item.dir_id !== SHARED_DRIVES_DIR_ID &&\n          !item.path?.startsWith(TRASH_DIR_PATH) &&\n          !isReferencedBySharedDrive &&\n          (!showNextcloudFolder ? !isNextcloudShortcut(item) : true)\n        )\n      }) ?? [],\n    [folderList, sharedDriveIds, showNextcloudFolder]\n  )\n\n  return {\n    sharedDrives: transformedSharedDrives,\n    nonSharedDriveList,\n    sharedDrivesLoaded\n  }\n}\n\nexport { useTransformFolderListHasSharedDriveShortcuts }\n"
  },
  {
    "path": "src/hooks/useUpdateFavicon/constants.ts",
    "content": "export const FAVICON_BY_MIMETYPE: Record<string, string | undefined> = {\n  text: '/favicons/icon-onlyoffice-text.ico',\n  sheet: '/favicons/icon-onlyoffice-sheet.ico',\n  slide: '/favicons/icon-onlyoffice-slide.ico'\n}\n"
  },
  {
    "path": "src/hooks/useUpdateFavicon/helpers.spec.js",
    "content": "import { updateFavicon } from './helpers'\n\nconst mockQuerySelectorAll = jest.fn()\nconst mockAppendChild = jest.fn()\nconst mockCreateElement = jest.fn()\n\nconst createMockLinkElement = (href = '/assets/favicon.ico') => ({\n  rel: '',\n  type: '',\n  href,\n  setAttribute: jest.fn()\n})\n\ndescribe('updateFavicon', () => {\n  beforeEach(() => {\n    jest.clearAllMocks()\n\n    Object.defineProperty(document, 'querySelectorAll', {\n      value: mockQuerySelectorAll,\n      writable: true\n    })\n\n    Object.defineProperty(document, 'createElement', {\n      value: mockCreateElement,\n      writable: true\n    })\n\n    Object.defineProperty(document.head, 'appendChild', {\n      value: mockAppendChild,\n      writable: true\n    })\n\n    mockCreateElement.mockReturnValue(createMockLinkElement())\n  })\n\n  it('should return early when faviconUrl is empty', () => {\n    updateFavicon('')\n\n    expect(mockQuerySelectorAll).not.toHaveBeenCalled()\n    expect(mockAppendChild).not.toHaveBeenCalled()\n  })\n\n  it('should create new favicon link when no links exist in DOM', () => {\n    const mockNewLink = createMockLinkElement()\n    mockCreateElement.mockReturnValue(mockNewLink)\n    mockQuerySelectorAll.mockReturnValue([])\n\n    updateFavicon('/favicons/icon-onlyoffice-text.ico')\n\n    expect(mockCreateElement).toHaveBeenCalledWith('link')\n    expect(mockNewLink.rel).toBe('icon')\n    expect(mockNewLink.type).toBe('image/svg+xml')\n    expect(mockNewLink.href).toBe('/favicons/icon-onlyoffice-text.ico')\n    expect(mockAppendChild).toHaveBeenCalledWith(mockNewLink)\n  })\n\n  it('should not update favicon when correct favicon is already applied', () => {\n    const mockLink = createMockLinkElement('/favicons/icon-onlyoffice-text.ico')\n    mockQuerySelectorAll.mockReturnValue([mockLink])\n\n    updateFavicon('/favicons/icon-onlyoffice-text.ico')\n\n    expect(mockLink.href).toBe('/favicons/icon-onlyoffice-text.ico')\n  })\n\n  it('should update favicon when current favicon differs from target', () => {\n    const mockLink = createMockLinkElement(\n      '/favicons/icon-onlyoffice-sheet.ico'\n    )\n    mockQuerySelectorAll.mockReturnValue([mockLink])\n\n    updateFavicon('/favicons/icon-onlyoffice-text.ico')\n\n    expect(mockLink.href).toBe('/favicons/icon-onlyoffice-text.ico')\n  })\n\n  it('should update all favicon links when multiple exist', () => {\n    const mockLink1 = createMockLinkElement('/assets/favicon.ico')\n    const mockLink2 = createMockLinkElement('/assets/favicon.ico')\n    mockQuerySelectorAll.mockReturnValue([mockLink1, mockLink2])\n\n    updateFavicon('/favicons/icon-onlyoffice-text.ico')\n\n    expect(mockLink1.href).toBe('/favicons/icon-onlyoffice-text.ico')\n    expect(mockLink2.href).toBe('/favicons/icon-onlyoffice-text.ico')\n  })\n\n  it('should restore original favicon', () => {\n    const mockLink = createMockLinkElement('/favicons/icon-onlyoffice-text.ico')\n    mockQuerySelectorAll.mockReturnValue([mockLink])\n\n    updateFavicon('/assets/favicon.ico')\n\n    expect(mockLink.href).toBe('/assets/favicon.ico')\n  })\n})\n"
  },
  {
    "path": "src/hooks/useUpdateFavicon/helpers.ts",
    "content": "/**\n * Updates all favicon link elements in the document head\n *\n * @param {string}faviconUrl - The URL of the favicon to set\n */\nexport const updateFavicon = (faviconUrl: string): void => {\n  if (!faviconUrl) return\n\n  const links = document.querySelectorAll<HTMLLinkElement>(\"link[rel~='icon']\")\n\n  if (!links.length) {\n    const link = document.createElement('link')\n    link.rel = 'icon'\n    link.type = 'image/svg+xml'\n    link.href = faviconUrl\n    document.head.appendChild(link)\n    return\n  }\n\n  const currentFavicon = links[0].href\n  if (currentFavicon === faviconUrl) {\n    return\n  }\n\n  links.forEach(link => {\n    link.href = faviconUrl\n  })\n}\n"
  },
  {
    "path": "src/hooks/useUpdateFavicon/index.spec.jsx",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport flag from 'cozy-flags'\n\nimport useUpdateFavicon from '.'\n\njest.mock('cozy-flags')\njest.mock('@/lib/getFileMimetype', () => ({\n  getFileMimetype: jest.fn()\n}))\n\nconst mockFlag = flag\n\nconst mockQuerySelector = jest.fn()\nconst mockQuerySelectorAll = jest.fn()\n\nconst createMockLinkElement = (href = '/assets/favicon.ico') => ({\n  rel: '',\n  type: '',\n  href\n})\n\ndescribe('useUpdateFavicon', () => {\n  beforeEach(() => {\n    jest.clearAllMocks()\n\n    Object.defineProperty(document, 'querySelector', {\n      value: mockQuerySelector,\n      writable: true\n    })\n\n    Object.defineProperty(document, 'querySelectorAll', {\n      value: mockQuerySelectorAll,\n      writable: true\n    })\n\n    mockFlag.mockReturnValue(true)\n    mockQuerySelector.mockReturnValue(createMockLinkElement())\n  })\n\n  it('should update favicon for OnlyOffice text documents', () => {\n    const file = {\n      _id: '1',\n      name: 'document.docx',\n      mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'\n    }\n\n    const { getFileMimetype } = require('@/lib/getFileMimetype')\n    getFileMimetype.mockReturnValue(() => 'text')\n\n    const originalLink = createMockLinkElement('/assets/favicon.ico')\n    const mockLink = createMockLinkElement('/assets/favicon.ico')\n\n    mockQuerySelector.mockReturnValue(originalLink)\n    mockQuerySelectorAll.mockReturnValue([mockLink])\n\n    renderHook(() => useUpdateFavicon(file, 'loaded'))\n\n    expect(mockLink.href).toBe('/favicons/icon-onlyoffice-text.ico')\n  })\n\n  it('should update favicon for OnlyOffice spreadsheet documents', () => {\n    const file = {\n      _id: '1',\n      name: 'spreadsheet.xlsx',\n      mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'\n    }\n\n    const { getFileMimetype } = require('@/lib/getFileMimetype')\n    getFileMimetype.mockReturnValue(() => 'sheet')\n\n    const originalLink = createMockLinkElement('/assets/favicon.ico')\n    const mockLink = createMockLinkElement('/assets/favicon.ico')\n\n    mockQuerySelector.mockReturnValue(originalLink)\n    mockQuerySelectorAll.mockReturnValue([mockLink])\n\n    renderHook(() => useUpdateFavicon(file, 'loaded'))\n\n    expect(mockLink.href).toBe('/favicons/icon-onlyoffice-sheet.ico')\n  })\n\n  it('should update favicon for OnlyOffice presentation documents', () => {\n    const file = {\n      _id: '1',\n      name: 'presentation.pptx',\n      mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation'\n    }\n\n    const { getFileMimetype } = require('@/lib/getFileMimetype')\n    getFileMimetype.mockReturnValue(() => 'slide')\n\n    const originalLink = createMockLinkElement('/assets/favicon.ico')\n    const mockLink = createMockLinkElement('/assets/favicon.ico')\n\n    mockQuerySelector.mockReturnValue(originalLink)\n    mockQuerySelectorAll.mockReturnValue([mockLink])\n\n    renderHook(() => useUpdateFavicon(file, 'loaded'))\n\n    expect(mockLink.href).toBe('/favicons/icon-onlyoffice-slide.ico')\n  })\n\n  it('should use original favicon for non-OnlyOffice files', () => {\n    const file = {\n      _id: '1',\n      name: 'image.jpg',\n      mime: 'image/jpeg'\n    }\n\n    const { getFileMimetype } = require('@/lib/getFileMimetype')\n    getFileMimetype.mockReturnValue(() => 'image')\n\n    const originalFaviconLink = createMockLinkElement('/custom/favicon.ico')\n    const mockLink = createMockLinkElement('/custom/favicon.ico')\n\n    mockQuerySelector.mockReturnValue(originalFaviconLink)\n    mockQuerySelectorAll.mockReturnValue([mockLink])\n\n    renderHook(() => useUpdateFavicon(file, 'loaded'))\n\n    expect(mockLink.href).toBe('/custom/favicon.ico')\n  })\n\n  it('should restore original favicon on cleanup', () => {\n    const file = {\n      _id: '1',\n      name: 'document.docx',\n      mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'\n    }\n\n    const { getFileMimetype } = require('@/lib/getFileMimetype')\n    getFileMimetype.mockReturnValue(() => 'text')\n\n    const originalFaviconLink = createMockLinkElement('/original/favicon.ico')\n    const mockLink = createMockLinkElement('/original/favicon.ico')\n\n    mockQuerySelector.mockReturnValue(originalFaviconLink)\n    mockQuerySelectorAll.mockReturnValue([mockLink])\n\n    const { unmount } = renderHook(() => useUpdateFavicon(file, 'loaded'))\n\n    // Favicon should be updated to OnlyOffice icon\n    expect(mockLink.href).toBe('/favicons/icon-onlyoffice-text.ico')\n\n    unmount()\n\n    // Favicon should be restored to original\n    expect(mockLink.href).toBe('/original/favicon.ico')\n  })\n\n  it('should not update favicon when fetchStatus is not loaded', () => {\n    const file = {\n      _id: '1',\n      name: 'document.docx',\n      mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'\n    }\n\n    const { getFileMimetype } = require('@/lib/getFileMimetype')\n    getFileMimetype.mockReturnValue(() => 'text')\n\n    const mockLink = createMockLinkElement('/assets/favicon.ico')\n    mockQuerySelectorAll.mockReturnValue([mockLink])\n\n    renderHook(() => useUpdateFavicon(file, 'loading'))\n\n    expect(mockLink.href).toBe('/assets/favicon.ico')\n  })\n\n  it('should not update favicon when file is undefined', () => {\n    const mockLink = createMockLinkElement('/assets/favicon.ico')\n    mockQuerySelectorAll.mockReturnValue([mockLink])\n\n    renderHook(() => useUpdateFavicon(undefined, 'loaded'))\n\n    expect(mockLink.href).toBe('/assets/favicon.ico')\n  })\n})\n"
  },
  {
    "path": "src/hooks/useUpdateFavicon/index.tsx",
    "content": "import { useEffect, useRef } from 'react'\n\nimport { IOCozyFile } from 'cozy-client/types/types'\n\nimport { updateFavicon } from './helpers'\n\nimport { FAVICON_BY_MIMETYPE } from '@/hooks/useUpdateFavicon/constants'\nimport { getFileMimetype } from '@/lib/getFileMimetype'\n\nconst useUpdateFavicon = (\n  file: IOCozyFile | undefined,\n  fetchStatus: string\n): void => {\n  const originalFaviconUrlRef = useRef<string>()\n\n  useEffect(() => {\n    const originalFavicon =\n      document.querySelector<HTMLLinkElement>(\"link[rel~='icon']\")\n\n    if (originalFavicon) {\n      originalFaviconUrlRef.current = originalFavicon.href\n    }\n\n    return (): void => {\n      const originalUrl = originalFaviconUrlRef.current\n\n      if (originalUrl) {\n        updateFavicon(originalUrl)\n      }\n    }\n  }, [])\n\n  useEffect(() => {\n    if (fetchStatus !== 'loaded' || !file) {\n      return\n    }\n\n    const type = getFileMimetype(FAVICON_BY_MIMETYPE)(\n      file.mime,\n      file.name\n    ) as string\n\n    const faviconUrl =\n      FAVICON_BY_MIMETYPE[type] ?? originalFaviconUrlRef.current\n\n    if (faviconUrl) {\n      updateFavicon(faviconUrl)\n    }\n  }, [file, fetchStatus])\n}\n\nexport default useUpdateFavicon\n"
  },
  {
    "path": "src/lib/AcceptingSharingContext.jsx",
    "content": "import React, { createContext, useState } from 'react'\n\nconst AcceptingSharingContext = createContext()\n\nconst AcceptingSharingProvider = ({ children }) => {\n  const [sharingsValue, setSharingsValue] = useState({})\n  const [fileValue, setFileValue] = useState()\n  const contextValue = {\n    sharingsValue,\n    setSharingsValue,\n    fileValue,\n    setFileValue\n  }\n\n  return (\n    <AcceptingSharingContext.Provider value={contextValue}>\n      {children}\n    </AcceptingSharingContext.Provider>\n  )\n}\n\nexport default AcceptingSharingContext\n\nexport { AcceptingSharingProvider }\n"
  },
  {
    "path": "src/lib/DriveProvider.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { CozyProvider } from 'cozy-client'\nimport { DataProxyProvider } from 'cozy-dataproxy-lib'\nimport {\n  VaultUnlockProvider,\n  VaultProvider,\n  VaultUnlockPlaceholder\n} from 'cozy-keys-lib'\nimport SharingProvider, { NativeFileSharingProvider } from 'cozy-sharing'\nimport AlertProvider from 'cozy-ui/transpiled/react/providers/Alert'\nimport { BreakpointsProvider } from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport CozyTheme from 'cozy-ui-plus/dist/providers/CozyTheme'\nimport { I18n } from 'twake-i18n'\n\nimport RightClickProvider from '@/components/RightClick/RightClickProvider'\nimport FabProvider from '@/lib/FabProvider'\nimport { DOCTYPE_APPS, DOCTYPE_CONTACTS, DOCTYPE_FILES } from '@/lib/doctypes'\nimport { usePublicContext } from '@/modules/public/PublicProvider'\n\nconst DriveProvider = ({ client, lang, polyglot, dictRequire, children }) => {\n  const { isPublic } = usePublicContext()\n\n  return (\n    <I18n lang={lang} polyglot={polyglot} dictRequire={dictRequire}>\n      <CozyProvider client={client}>\n        <DataProxyWrapper isPublic={isPublic}>\n          <VaultProvider cozyClient={client}>\n            <VaultUnlockProvider>\n              <SharingProvider\n                doctype=\"io.cozy.files\"\n                documentType=\"Files\"\n                isPublic={isPublic}\n              >\n                <NativeFileSharingProvider>\n                  <CozyTheme ignoreCozySettings={isPublic} className=\"u-w-100\">\n                    <BreakpointsProvider>\n                      <AlertProvider>\n                        <VaultUnlockPlaceholder />\n                        <FabProvider>\n                          <RightClickProvider>{children}</RightClickProvider>\n                        </FabProvider>\n                      </AlertProvider>\n                    </BreakpointsProvider>\n                  </CozyTheme>\n                </NativeFileSharingProvider>\n              </SharingProvider>\n            </VaultUnlockProvider>\n          </VaultProvider>\n        </DataProxyWrapper>\n      </CozyProvider>\n    </I18n>\n  )\n}\n\nconst DataProxyWrapper = ({ children, isPublic }) => {\n  if (isPublic) {\n    // Do not include DataProxy for public sharings\n    return children\n  }\n  return (\n    <DataProxyProvider\n      options={{\n        doctypes: [DOCTYPE_FILES, DOCTYPE_CONTACTS, DOCTYPE_APPS]\n      }}\n    >\n      {children}\n    </DataProxyProvider>\n  )\n}\n\nDriveProvider.propTypes = {\n  client: PropTypes.object.isRequired,\n  lang: PropTypes.string.isRequired,\n  polyglot: PropTypes.object,\n  dictRequire: PropTypes.func\n}\n\nexport default DriveProvider\n"
  },
  {
    "path": "src/lib/FabProvider.jsx",
    "content": "import React, { createContext, useState } from 'react'\n\nexport const FabContext = createContext()\n\nconst FabProvider = ({ children }) => {\n  const [isFabDisplayed, setIsFabDisplayed] = useState(false)\n\n  return (\n    <FabContext.Provider value={{ isFabDisplayed, setIsFabDisplayed }}>\n      {children}\n    </FabContext.Provider>\n  )\n}\n\nexport default FabProvider\n"
  },
  {
    "path": "src/lib/FuzzyPathSearch.js",
    "content": "import { remove as removeDiacritics } from 'diacritics'\n\n// Search for keywords inside a list of files and folders, while being permissive regardig the order of words\nclass FuzzyPathSearch {\n  constructor(files = []) {\n    // files must have a `path` and `name` property\n    this.files = files\n    this.previousQuery = []\n    this.previousSuggestions = files\n  }\n\n  search(query) {\n    if (!query) return []\n\n    const queryArray = removeDiacritics(\n      query.replace(/\\//g, ' ').trim().toLowerCase()\n    ).split(' ')\n    const preparedQuery = queryArray.map(word => ({\n      word,\n      isAugmentedWord: false,\n      isNewWord: true\n    }))\n\n    const isQueryAugmented = this.isAugmentingPreviousQuery(preparedQuery)\n    const sortedQuery = isQueryAugmented\n      ? this.sortQueryByRevelance(preparedQuery)\n      : this.sortQuerybyLength(preparedQuery)\n    let suggestions\n\n    if (isQueryAugmented && this.previousSuggestions.length !== 0) {\n      // the new query is just a more selective version of the previous one, so we narrow down the existing list\n      suggestions = this.filterAndScore(\n        this.previousSuggestions,\n        sortedQuery.map(segment => segment.word)\n      )\n    } else {\n      suggestions = this.filterAndScore(\n        this.files,\n        sortedQuery.map(segment => segment.word)\n      )\n    }\n\n    this.previousQuery = sortedQuery\n    this.previousSuggestions = suggestions\n\n    return suggestions\n  }\n\n  isAugmentingPreviousQuery(query) {\n    for (let currentQuerySegment of query) {\n      let isInPreviousQuery = false\n      for (let previousQuerySegment of this.previousQuery) {\n        if (currentQuerySegment.word.includes(previousQuerySegment.word)) {\n          isInPreviousQuery = true\n          break\n        }\n      }\n\n      // we found a word in the current query that was not included in the previous query, so we consider it a completely new query\n      if (isInPreviousQuery === false) return false\n    }\n\n    // all words are in the previous query\n    return true\n  }\n\n  sortQueryByRevelance(query) {\n    // query terms are sorted in two categories: those that are new or have changed, and therefore may further reduce the set of results, are prioritzed. Those that were there and have not changed come second.\n    // finally, longer words are placed first to allow discarding files earlier in the scoring loop\n    let priorizedWords = []\n    let wordsFromPreviousQuery = []\n\n    for (let currentQuerySegment of query) {\n      let wasInPreviousQuery = false\n      for (let previousQuerySegment of this.previousQuery) {\n        if (currentQuerySegment.word.includes(previousQuerySegment.word)) {\n          if (currentQuerySegment.word !== previousQuerySegment.word) {\n            currentQuerySegment.isAugmentedWord = true\n            priorizedWords.push(currentQuerySegment)\n          } else {\n            currentQuerySegment.isNewWord = false\n            wordsFromPreviousQuery.push(currentQuerySegment)\n          }\n\n          wasInPreviousQuery = true\n          continue\n        }\n      }\n\n      // this segment wasn't included in any previous query segment so it's a new word and we prioritize it\n      if (!wasInPreviousQuery) priorizedWords.push(currentQuerySegment)\n    }\n\n    return this.sortQuerybyLength(priorizedWords).concat(\n      this.sortQuerybyLength(wordsFromPreviousQuery)\n    )\n  }\n\n  sortQuerybyLength(query) {\n    return query.sort((a, b) => b.word.length - a.word.length)\n  }\n\n  filterAndScore(files, words) {\n    const suggestions = []\n\n    files.forEach(file => {\n      let fileScore = 0\n      const pathArray = removeDiacritics(\n        (file.path + '/' + file.name).toLowerCase()\n      )\n        .split('/')\n        .filter(pathChunk => !!pathChunk)\n\n      for (let word of words) {\n        // let the magic begin...\n        // essentialy, matched words that are at the end of the path get better scores\n        let wordScore = 0\n        let wordOccurenceValue = 10000\n        let firstOccurence = true\n        const maxDepth = pathArray.length\n\n        for (let depth = 0; depth < maxDepth; ++depth) {\n          let dirName = pathArray[depth]\n\n          if (dirName.includes(word)) {\n            if (firstOccurence) {\n              wordOccurenceValue = 52428800 // that's 2^19 * 100\n              wordScore +=\n                (wordOccurenceValue / 2) * (1 + word.length / dirName.length)\n              firstOccurence = false\n            } else {\n              wordScore -=\n                wordOccurenceValue * (1 - word.length / dirName.length)\n            }\n\n            wordOccurenceValue /= 2\n          } else {\n            wordScore -= wordOccurenceValue\n            wordOccurenceValue /= 2\n            if (depth === maxDepth - 1) {\n              // make the penality bigger if the last part of the path doesn't include the word at all\n              wordScore /= 2\n            }\n          }\n\n          wordOccurenceValue /= 2\n        }\n\n        if (wordScore < 0) return\n\n        fileScore += wordScore\n      }\n\n      if (fileScore > 0) {\n        suggestions.push({\n          file,\n          score: fileScore\n        })\n      }\n    })\n\n    suggestions.sort((a, b) => {\n      const score = b.score - a.score\n      return score !== 0 ? score : a.file.path.localeCompare(b.file.path)\n    })\n\n    return suggestions.map(suggestion => suggestion.file)\n  }\n}\n\nexport default FuzzyPathSearch\n"
  },
  {
    "path": "src/lib/FuzzyPathSearch.spec.js",
    "content": "import FuzzyPathSearch from './FuzzyPathSearch'\n\ndescribe('simple search', () => {\n  const guitars = [\n    { name: 'fender stratocaster', path: '' },\n    { name: 'fender telecaster', path: '' },\n    { name: 'gibson SG', path: '' }\n  ]\n\n  let fps\n  beforeEach(() => {\n    fps = new FuzzyPathSearch(guitars)\n  })\n\n  it('should return an exact match', () => {\n    const query = 'fender telecaster'\n    const result = fps.search(query)\n\n    expect(result).toBeInstanceOf(Array)\n    expect(result.length).toEqual(1)\n    expect(result[0]).toBe(guitars[1])\n  })\n\n  it('should return all possible matches', () => {\n    const query = 'fender'\n    const result = fps.search(query)\n\n    expect(result).toBeInstanceOf(Array)\n    expect(result.length).toEqual(2)\n    expect(result.includes(guitars[0])).toBe(true)\n    expect(result.includes(guitars[1])).toBe(true)\n  })\n})\n\ndescribe('search with path', () => {\n  const guitars = [\n    { name: 'stratocaster', path: '/fender/' },\n    { name: 'telecaster', path: '/fender/' },\n    { name: 'SG', path: '/Gibson/' }\n  ]\n\n  let fps\n  beforeEach(() => {\n    fps = new FuzzyPathSearch(guitars)\n  })\n\n  it('should return an exact match', () => {\n    const query = 'fender telecaster'\n    const result = fps.search(query)\n\n    expect(result).toBeInstanceOf(Array)\n    expect(result.length).toEqual(1)\n    expect(result[0]).toBe(guitars[1])\n  })\n\n  it('should return all possible matches', () => {\n    const query = 'fender'\n    const result = fps.search(query)\n\n    expect(result).toBeInstanceOf(Array)\n    expect(result.length).toEqual(2)\n    expect(result.includes(guitars[0])).toBe(true)\n    expect(result.includes(guitars[1])).toBe(true)\n  })\n})\n\ndescribe('malformed queries', () => {\n  const guitars = [\n    { name: 'fender stratocaster', path: '' },\n    { name: 'fender telecaster', path: '' },\n    { name: 'gibson SG', path: '' }\n  ]\n\n  let fps\n  beforeEach(() => {\n    fps = new FuzzyPathSearch(guitars)\n  })\n\n  it('should handle different orders', () => {\n    const query1 = 'telecaster fender'\n    const result1 = fps.search(query1)\n\n    const query2 = 'fender telecaster'\n    const result2 = fps.search(query2)\n\n    expect(result1).toEqual(result2)\n  })\n\n  it('should handle diacritics', () => {\n    const query = 'télécaster fender'\n    const result = fps.search(query)\n\n    expect(result).toBeInstanceOf(Array)\n    expect(result.length).toEqual(1)\n    expect(result[0]).toBe(guitars[1])\n  })\n\n  it('should handle extra spaces', () => {\n    const query = 'fender   telecaster'\n    const result = fps.search(query)\n\n    expect(result).toBeInstanceOf(Array)\n    expect(result.length).toEqual(1)\n    expect(result[0]).toBe(guitars[1])\n  })\n\n  it('should not care about casing', () => {\n    const query = 'FENDER TeLeCASTER'\n    const result = fps.search(query)\n\n    expect(result).toBeInstanceOf(Array)\n    expect(result.length).toEqual(1)\n    expect(result[0]).toBe(guitars[1])\n  })\n})\n\ndescribe('result ordering', () => {\n  it('should favor names over pathes', () => {\n    const guitars = [\n      { name: 'telecaster', path: '/fender' },\n      { name: 'fender', path: '/telecaster' }\n    ]\n    const fps = new FuzzyPathSearch(guitars)\n\n    const query = 'tele'\n    const result = fps.search(query)\n\n    expect(result).toBeInstanceOf(Array)\n    expect(result.length).toEqual(2)\n    expect(result[0]).toBe(guitars[0])\n    expect(result[1]).toBe(guitars[1])\n  })\n\n  it('should not care about the input order', () => {\n    const guitars = [\n      { name: 'telecaster', path: '/fender' },\n      { name: 'fender', path: '/telecaster' }\n    ]\n    const fps = new FuzzyPathSearch(guitars)\n\n    const query = 'tele'\n    const result = fps.search(query)\n\n    expect(result).toBeInstanceOf(Array)\n    expect(result.length).toEqual(2)\n    expect(result[0]).toBe(guitars[0])\n    expect(result[1]).toBe(guitars[1])\n\n    const reversed = guitars.reverse()\n    const reversedFps = new FuzzyPathSearch(reversed)\n\n    const reversedResult = reversedFps.search(query)\n\n    expect(reversedResult).toBeInstanceOf(Array)\n    expect(reversedResult.length).toEqual(2)\n    expect(reversedResult[0]).toBe(guitars[1])\n    expect(reversedResult[1]).toBe(guitars[0])\n  })\n\n  it('should favor matches nearer the start of the path', () => {\n    const guitars = [\n      { name: '2015', path: '/fender/telecaster/stratocaster/' },\n      { name: '2015', path: '/fender/stratocaster/' }\n    ]\n\n    const fps = new FuzzyPathSearch(guitars)\n    const query = 'stratocaster'\n    const result = fps.search(query)\n\n    expect(result).toBeInstanceOf(Array)\n    expect(result.length).toEqual(2)\n    expect(result[0]).toBe(guitars[1])\n    expect(result[1]).toBe(guitars[0])\n  })\n\n  it('should fallback to the shortest path', () => {\n    const guitars = [\n      { name: '2015', path: '/fender/telecaster/stratocaster/' },\n      { name: '2015', path: '/fender/stratocaster/' }\n    ]\n\n    const fps = new FuzzyPathSearch(guitars)\n    const query = '2015'\n    const result = fps.search(query)\n\n    expect(result).toBeInstanceOf(Array)\n    expect(result.length).toEqual(2)\n    expect(result[0]).toBe(guitars[1])\n    expect(result[1]).toBe(guitars[0])\n  })\n})\n\ndescribe('successive searches', () => {\n  it('should filter results as the query gets longer', () => {\n    const guitars = [\n      { name: 'fender stratocaster', path: '' },\n      { name: 'fender telecaster', path: '' },\n      { name: 'gibson SG', path: '' }\n    ]\n    const fps = new FuzzyPathSearch(guitars)\n\n    const result1 = fps.search('caster')\n\n    expect(result1).toBeInstanceOf(Array)\n    expect(result1.length).toEqual(2)\n    expect(result1.includes(guitars[0])).toBe(true)\n    expect(result1.includes(guitars[1])).toBe(true)\n\n    const result2 = fps.search('caster tele')\n\n    expect(result2).toBeInstanceOf(Array)\n    expect(result2.length).toEqual(1)\n    expect(result2.includes(guitars[1])).toBe(true)\n  })\n\n  it('should reset when queries backtrack', () => {\n    const guitars = [\n      { name: 'fender stratocaster', path: '' },\n      { name: 'fender telecaster', path: '' },\n      { name: 'gibson SG', path: '' }\n    ]\n    const fps = new FuzzyPathSearch(guitars)\n\n    const result1 = fps.search('telecaster')\n\n    expect(result1).toBeInstanceOf(Array)\n    expect(result1.length).toEqual(1)\n    expect(result1.includes(guitars[1])).toBe(true)\n\n    const result2 = fps.search('caster')\n\n    expect(result2).toBeInstanceOf(Array)\n    expect(result2.length).toEqual(2)\n    expect(result2.includes(guitars[0])).toBe(true)\n    expect(result2.includes(guitars[1])).toBe(true)\n  })\n})\n"
  },
  {
    "path": "src/lib/ModalContext.tsx",
    "content": "import React, { useState, useCallback, useContext, ReactNode } from 'react'\n\ninterface TModalContext {\n  modalStack: JSX.Element[]\n  pushModal: (modal: JSX.Element) => void\n  popModal: () => void\n}\n\nexport const ModalContext = React.createContext<TModalContext | undefined>(\n  undefined\n)\n\ninterface ModalContextProviderProps {\n  children: ReactNode\n}\n\nexport const ModalContextProvider: React.FC<ModalContextProviderProps> = ({\n  children\n}) => {\n  const [modalStack, setModalStack] = useState<JSX.Element[]>([])\n\n  const pushModal = useCallback((modal: JSX.Element) => {\n    setModalStack(prevStack => [...prevStack, modal])\n  }, [])\n\n  const popModal = useCallback(() => {\n    setModalStack(prevStack => prevStack.slice(0, prevStack.length - 1))\n  }, [])\n\n  return (\n    <ModalContext.Provider value={{ modalStack, pushModal, popModal }}>\n      {children}\n    </ModalContext.Provider>\n  )\n}\n\nexport const useModalContext = (): TModalContext => {\n  const context = useContext(ModalContext)\n  if (!context) {\n    throw new Error(\n      'useModalContext must be used within a ModalContextProvider'\n    )\n  }\n  return context\n}\n\nexport const ModalStack = (): JSX.Element | null => {\n  const { modalStack } = useModalContext()\n\n  if (modalStack.length === 0) return null\n  else return modalStack[modalStack.length - 1]\n}\n"
  },
  {
    "path": "src/lib/ThumbnailSizeContext.tsx",
    "content": "import React, { useState, useCallback, useContext, createContext } from 'react'\n\ninterface ThumbnailSizeContextProps {\n  isBigThumbnail: boolean\n  toggleThumbnailSize: () => void\n}\n\nconst ThumbnailSizeContext = createContext<ThumbnailSizeContextProps>({\n  isBigThumbnail: false,\n  toggleThumbnailSize: () => {}\n})\n\nconst ThumbnailSizeContextProvider: React.FC = ({ children }) => {\n  const [isBigThumbnail, setIsBigThumbnail] = useState(false)\n  const toggleThumbnailSize = useCallback(\n    () => setIsBigThumbnail(!isBigThumbnail),\n    [isBigThumbnail, setIsBigThumbnail]\n  )\n\n  return (\n    <ThumbnailSizeContext.Provider\n      value={{ isBigThumbnail, toggleThumbnailSize }}\n    >\n      {children}\n    </ThumbnailSizeContext.Provider>\n  )\n}\n\nconst useThumbnailSizeContext = (): ThumbnailSizeContextProps =>\n  useContext(ThumbnailSizeContext)\n\nexport {\n  ThumbnailSizeContext,\n  ThumbnailSizeContextProvider,\n  useThumbnailSizeContext\n}\n"
  },
  {
    "path": "src/lib/ViewSwitcherContext.tsx",
    "content": "import React, { useState, useContext, createContext, useEffect } from 'react'\n\nimport { useClient, Q } from 'cozy-client'\n\nimport logger from './logger'\n\nimport { DOCTYPE_FILES_SETTINGS } from '@/lib/doctypes'\n\ninterface QueryResult {\n  data: [\n    {\n      attributes: {\n        preferredDriveViewType: string\n      }\n    }\n  ]\n}\n// Constants\nconst DEFAULT_VIEW_TYPE = 'list'\n\ninterface ViewSwitcherContextProps {\n  viewType: string\n  switchView: (viewTypeParam: string) => Promise<void>\n}\n\nconst ViewSwitcherContext = createContext<ViewSwitcherContextProps>({\n  viewType: DEFAULT_VIEW_TYPE,\n  switchView: async () => Promise.resolve()\n})\n\nconst ViewSwitcherContextProvider: React.FC = ({ children }) => {\n  const client = useClient()\n  const [viewType, setViewType] = useState(DEFAULT_VIEW_TYPE)\n\n  useEffect(() => {\n    const load = async (): Promise<void> => {\n      if (!client) return\n\n      try {\n        const result = (await client.query(\n          Q(DOCTYPE_FILES_SETTINGS)\n        )) as QueryResult\n\n        if (!result?.data) return\n\n        const preferred = result?.data?.[0]?.attributes?.preferredDriveViewType\n\n        setViewType(preferred || DEFAULT_VIEW_TYPE)\n      } catch (error) {\n        logger.error('Failed to load settings:', error)\n        setViewType(DEFAULT_VIEW_TYPE)\n      }\n    }\n\n    void load()\n  }, [client])\n\n  const switchView = async (viewTypeParam: string): Promise<void> => {\n    setViewType(viewTypeParam)\n    if (!client) {\n      logger.warn('Client not available')\n\n      return\n    }\n\n    try {\n      const { data } = (await client.query(\n        Q(DOCTYPE_FILES_SETTINGS)\n      )) as QueryResult\n\n      if (!data) {\n        logger.warn('Settings not found')\n\n        return\n      }\n\n      const existing = data[0]\n\n      await client.save({\n        ...(existing || { _type: DOCTYPE_FILES_SETTINGS }),\n        attributes: {\n          ...(existing?.attributes || {}),\n          preferredDriveViewType: viewTypeParam\n        }\n      })\n    } catch (error) {\n      logger.error('Failed to save view preference:', error)\n    }\n  }\n\n  return (\n    <ViewSwitcherContext.Provider value={{ viewType, switchView }}>\n      {children}\n    </ViewSwitcherContext.Provider>\n  )\n}\n\nconst useViewSwitcherContext = (): ViewSwitcherContextProps =>\n  useContext(ViewSwitcherContext)\n\nexport {\n  ViewSwitcherContext,\n  ViewSwitcherContextProvider,\n  useViewSwitcherContext\n}\n"
  },
  {
    "path": "src/lib/appMetadata.js",
    "content": "import manifest from '../../manifest.webapp'\n\nconst appMetadata = {\n  slug: manifest.slug,\n  version: manifest.version,\n  name: manifest.name,\n  prefix: manifest.name_prefix\n}\n\nexport default appMetadata\n"
  },
  {
    "path": "src/lib/dacc/dacc-run.js",
    "content": "import endOfMonth from 'date-fns/endOfMonth'\nimport format from 'date-fns/format'\nimport startOfMonth from 'date-fns/startOfMonth'\nimport subMonths from 'date-fns/subMonths'\n\nimport CozyClient from 'cozy-client'\nimport flag from 'cozy-flags'\nimport log from 'cozy-logger'\n\nimport { aggregateFilesSize, sendToRemoteDoctype } from '@/lib/dacc/dacc'\nimport { schema } from '@/lib/doctypes'\n\n/**\n * This service aggregates files size by createdByApps slug and send them to the DACC.\n * See https://github.com/cozy/DACC for more insights about the DACC.\n * The service relies on a flag that contains the following information:\n *   - measureName: the name of the dacc measure\n *   - remoteDoctype: the remote doctype to use\n *   - nonExcludedGroupLabel: when set, it is used to aggregate all the slugs not maching the excludedSlug\n *   - excludedSlug: used to exclude a slug from the total aggregation\n */\nexport const run = async () => {\n  log('info', 'Start dacc service')\n\n  const client = CozyClient.fromEnv(process.env, { schema })\n\n  await flag.initialize(client)\n  const daccFileSizeFlag = flag('drive.dacc-files-size-by-slug')\n  if (!daccFileSizeFlag) {\n    return\n  }\n  const {\n    excludedSlug,\n    nonExcludedGroupLabel,\n    measureName,\n    remoteDoctype,\n    maxFileDateQuery\n  } = daccFileSizeFlag\n\n  const aggregationDate = new Date(\n    maxFileDateQuery || endOfMonth(subMonths(new Date(), 1))\n  )\n\n  const sizesBySlug = await aggregateFilesSize(client, aggregationDate, {\n    excludedSlug,\n    nonExcludedGroupLabel\n  })\n  if (Object.keys(sizesBySlug).length < 1) {\n    log(\n      'info',\n      `No files found to aggregate with date ${aggregationDate.toISOString()}`\n    )\n  }\n\n  const startDateMeasure = format(startOfMonth(aggregationDate), 'yyyy-LL-dd')\n\n  await sendToRemoteDoctype(client, remoteDoctype, sizesBySlug, {\n    measureName,\n    startDate: startDateMeasure\n  })\n}\n"
  },
  {
    "path": "src/lib/dacc/dacc-run.spec.js",
    "content": "import endOfMonth from 'date-fns/endOfMonth'\nimport subMonths from 'date-fns/subMonths'\n\nimport CozyClient from 'cozy-client'\nimport flag from 'cozy-flags'\nimport log from 'cozy-logger'\n\nimport { run } from './dacc-run'\n\nimport { aggregateFilesSize } from '@/lib/dacc/dacc'\n\njest.mock('cozy-flags')\njest.mock('cozy-client')\njest.mock('cozy-logger')\njest.mock('lib/dacc/dacc')\n\ndescribe('dacc', () => {\n  const maxGivenDate = '2022-01-01'\n  const maxDate = new Date(maxGivenDate)\n  beforeEach(() => {\n    flag.mockReturnValue({\n      excludedSlug: 'excludedSlug',\n      nonExcludedGroupLabel: 'nonExcludedGroupLabel',\n      measureName: 'measureName',\n      remoteDoctype: 'remoteDoctype',\n      maxFileDateQuery: maxGivenDate\n    })\n  })\n\n  afterEach(() => {\n    jest.resetAllMocks()\n  })\n\n  it('should do nothing when no flag is set', async () => {\n    // Given\n    flag.mockReturnValueOnce(null)\n\n    // When\n    await run()\n\n    // Then\n    expect(aggregateFilesSize).toHaveBeenCalledTimes(0)\n  })\n\n  it('should aggregateFilesSize with max file date query', async () => {\n    // Given\n    const client = 'client'\n    CozyClient.fromEnv.mockReturnValue(client)\n    aggregateFilesSize.mockResolvedValueOnce([])\n\n    // When\n    await run()\n\n    // Then\n    expect(aggregateFilesSize).toHaveBeenCalledWith(client, maxDate, {\n      excludedSlug: 'excludedSlug',\n      nonExcludedGroupLabel: 'nonExcludedGroupLabel'\n    })\n  })\n\n  it('should aggregateFilesSize with end date of this month when max file date query not found', async () => {\n    // Given\n    const client = 'client'\n    CozyClient.fromEnv.mockReturnValue(client)\n    aggregateFilesSize.mockResolvedValueOnce([])\n    flag.mockReturnValue({\n      excludedSlug: 'excludedSlug',\n      nonExcludedGroupLabel: 'nonExcludedGroupLabel',\n      measureName: 'measureName',\n      remoteDoctype: 'remoteDoctype'\n    })\n    const endOfThisMonth = new Date(endOfMonth(subMonths(new Date(), 1)))\n\n    // When\n    await run()\n\n    // Then\n    expect(aggregateFilesSize).toHaveBeenCalledWith(client, endOfThisMonth, {\n      excludedSlug: 'excludedSlug',\n      nonExcludedGroupLabel: 'nonExcludedGroupLabel'\n    })\n  })\n\n  it('should log when there is no sizes by slug', async () => {\n    // Given\n    aggregateFilesSize.mockResolvedValueOnce([])\n\n    // When\n    await run()\n\n    const date = new Date(maxDate).toISOString()\n\n    // Then\n    expect(log).toHaveBeenNthCalledWith(\n      2,\n      'info',\n      `No files found to aggregate with date ${date}`\n    )\n  })\n\n  it('should not log when there are sizes by slug', async () => {\n    // Given\n    aggregateFilesSize.mockResolvedValueOnce([{}])\n\n    // When\n    await run()\n\n    // Then\n    expect(log).toHaveBeenCalledTimes(1)\n  })\n})\n"
  },
  {
    "path": "src/lib/dacc/dacc.js",
    "content": "// @ts-check\nimport log from 'cozy-logger'\n\nimport { queryAllDocsWithFields } from '@/lib/dacc/query'\n\n/**\n * @typedef {object} Measure\n * See https://github.com/cozy/DACC for more insights\n *\n * @property {string} [createdBy] - The app slug that created the measure\n * @property {string} [measureName] - The measure name\n * @property {string} [startDate] - The startDate of the aggregation\n * @property {number} [value] - The measure value\n * @property {Array<object>} [groups] - The measure groups\n */\n\nconst sendMeasureToDACC = async (client, remoteDoctype, measure) => {\n  try {\n    log('info', `Send ${JSON.stringify(measure)} to ${remoteDoctype}`)\n    await client\n      .getStackClient()\n      .fetchJSON('POST', `/remote/${remoteDoctype}`, {\n        data: JSON.stringify(measure),\n        path: 'measure'\n      })\n  } catch (error) {\n    log(\n      'error',\n      `Error while sending measure to remote doctype: ${error.message}`\n    )\n    throw error\n  }\n}\n\n/**\n * Send measures to a remote doctype\n *\n * @param {object} client - The CozyClient instance\n * @param {string} remoteDoctype - The remote doctype to use\n * @param {object} sizesBySlug - The hash table of values by slug\n * @param {{startDate, measureName}} params - The measure params\n */\nexport const sendToRemoteDoctype = async (\n  client,\n  remoteDoctype,\n  sizesBySlug,\n  { startDate, measureName }\n) => {\n  const slugs = Object.keys(sizesBySlug)\n  log(\n    'info',\n    `Send ${slugs.length} measures ${measureName} on ${startDate} to ${remoteDoctype}...`\n  )\n  for (const slug of slugs) {\n    const measure = {\n      createdBy: 'drive',\n      measureName,\n      startDate,\n      value: sizesBySlug[slug],\n      groups: [{ slug: slug }]\n    }\n    await sendMeasureToDACC(client, remoteDoctype, measure)\n  }\n}\n\nconst convertFileSizeInMB = file => {\n  // The size is converted in MB to avoid too large values\n  return parseInt(file.size) / (1000 * 1000) // Size in million of Bytes (MB)\n}\n\n/**\n * Aggregate file size values by slug\n *\n * @param {object} client - The CozyClient instance\n * @param {Date} endDate - The max file date to query\n * @returns {Promise<object>} The hash table of values by slug\n */\nexport const aggregateFilesSize = async (\n  client,\n  endDate,\n  { excludedSlug = '', nonExcludedGroupLabel = '' } = {}\n) => {\n  const sizesBySlug = {\n    trashed: 0\n  }\n  const resp = await queryAllDocsWithFields(client)\n\n  for (const entry of resp) {\n    const file = entry.doc\n    const uploadedAt = new Date(file?.cozyMetadata?.uploadedAt || Date.now())\n    if (file.type !== 'file' || uploadedAt > endDate) {\n      // Skip this doc\n      continue\n    }\n    const slug = file.cozyMetadata?.createdByApp || 'unknown'\n    const sizeMB = convertFileSizeInMB(file)\n\n    if (file.trashed) {\n      // Special case for trashed files\n      sizesBySlug.trashed += sizeMB\n    } else {\n      if (slug in sizesBySlug) {\n        sizesBySlug[slug] += sizeMB\n      } else {\n        sizesBySlug[slug] = sizeMB\n      }\n    }\n  }\n\n  if (excludedSlug && nonExcludedGroupLabel) {\n    // Aggregate values\n    const totalNonExcluded = aggregateNonExcludedSlugs(\n      sizesBySlug,\n      excludedSlug\n    )\n    sizesBySlug[nonExcludedGroupLabel] = totalNonExcluded\n  }\n  // Round values\n  for (const slug of Object.keys(sizesBySlug)) {\n    sizesBySlug[slug] = Math.round(sizesBySlug[slug] * 1000) / 1000\n  }\n\n  return sizesBySlug\n}\n\n/**\n * Aggregate all values except for excluded slug\n *\n * @param {object} sizesBySlug - The hash table of values by slug\n * @param {string} exclusionSlug - The slug to exclude\n */\nexport const aggregateNonExcludedSlugs = (sizesBySlug, exclusionSlug) => {\n  let totalSize = 0\n  for (const slug of Object.keys(sizesBySlug)) {\n    if (!slug.includes(exclusionSlug) && slug !== 'trashed') {\n      totalSize += sizesBySlug[slug]\n    }\n  }\n  return totalSize\n}\n"
  },
  {
    "path": "src/lib/dacc/dacc.spec.js",
    "content": "import { aggregateFilesSize, aggregateNonExcludedSlugs } from '@/lib/dacc/dacc'\nimport { queryAllDocsWithFields } from '@/lib/dacc/query'\n\njest.mock('lib/dacc/query')\n\nconst mockedFilesQueryResponse = [\n  {\n    doc: {\n      type: 'file',\n      size: 1048576,\n      cozyMetadata: {\n        createdByApp: 'drive',\n        uploadedAt: '2021-01-01'\n      }\n    }\n  },\n  {\n    doc: {\n      type: 'file',\n      size: 3145728,\n      cozyMetadata: {\n        createdByApp: 'drive',\n        uploadedAt: '2021-01-01'\n      }\n    }\n  },\n  {\n    doc: {\n      type: 'file',\n      size: 4567892,\n      cozyMetadata: {\n        createdByApp: 'drive'\n      }\n    }\n  },\n  {\n    doc: {\n      type: 'file',\n      size: 2097152,\n      cozyMetadata: {\n        createdByApp: 'edf',\n        uploadedAt: '2021-01-01'\n      }\n    }\n  },\n  {\n    doc: {\n      type: 'file',\n      size: 8388608,\n      cozyMetadata: {\n        createdByApp: 'maif',\n        uploadedAt: '2021-01-01'\n      }\n    }\n  },\n  {\n    doc: {\n      type: 'file',\n      size: 6291456,\n      cozyMetadata: {\n        createdByApp: 'maif-vie',\n        uploadedAt: '2021-01-01'\n      }\n    }\n  },\n  {\n    doc: {\n      type: 'file',\n      trashed: true,\n      size: 2290000,\n      cozyMetadata: {\n        createdByApp: 'maif-vie',\n        uploadedAt: '2021-01-01'\n      }\n    }\n  }\n]\n\ndescribe('aggregateFilesSize', () => {\n  beforeEach(() => {\n    queryAllDocsWithFields.mockResolvedValue(mockedFilesQueryResponse)\n  })\n  it('should aggregate sizes by slug', async () => {\n    const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01'))\n    expect(Object.keys(sizesBySlug)).toEqual([\n      'trashed',\n      'drive',\n      'edf',\n      'maif',\n      'maif-vie'\n    ])\n    expect(sizesBySlug['drive']).toEqual(4.194)\n    expect(sizesBySlug['edf']).toEqual(2.097)\n    expect(sizesBySlug['maif']).toEqual(8.389)\n    expect(sizesBySlug['maif-vie']).toEqual(6.291)\n    expect(sizesBySlug['trashed']).toEqual(2.29)\n  })\n\n  it('should aggregate all sizes but excluded slug', async () => {\n    const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01'), {\n      excludedSlug: 'maif',\n      nonExcludedGroupLabel: 'not-maif'\n    })\n    const expectedValue =\n      Math.round((sizesBySlug['drive'] + sizesBySlug['edf']) * 1000) / 1000\n    expect(sizesBySlug['not-maif']).toEqual(expectedValue)\n  })\n\n  it('should skip docs not file or without uploadedAt', async () => {\n    queryAllDocsWithFields.mockResolvedValueOnce([\n      {\n        doc: {\n          type: 'file',\n          size: 4567892,\n          cozyMetadata: {\n            createdByApp: 'drive'\n          }\n        }\n      },\n      {\n        doc: {\n          type: 'directory'\n        }\n      }\n    ])\n    const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01'))\n    expect(sizesBySlug).toEqual({ trashed: 0 })\n  })\n})\n\ndescribe('aggregateNonExcludedSlugs', () => {\n  it('should aggregate all sizes but excluded slug', async () => {\n    const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01'))\n    const totalSize = aggregateNonExcludedSlugs(sizesBySlug, 'maif')\n    expect(totalSize).toEqual(sizesBySlug['drive'] + sizesBySlug['edf'])\n  })\n\n  it('should aggregate nothing when excluded slug is empty', async () => {\n    const sizesBySlug = await aggregateFilesSize(null, new Date('2022-01-01'))\n    const totalSize = aggregateNonExcludedSlugs(sizesBySlug, '')\n    expect(totalSize).toEqual(0)\n  })\n})\n"
  },
  {
    "path": "src/lib/dacc/query.js",
    "content": "import { DOCTYPE_FILES } from '@/lib/doctypes'\n\n/**\n * Query all files by filtering on required fields\n *\n * @param {object} client - The CozyClient instance\n * @returns {Promise<Array>} The files array\n */\nexport const queryAllDocsWithFields = async client => {\n  const resp = await client\n    .getStackClient()\n    .fetchJSON(\n      'GET',\n      `/data/${DOCTYPE_FILES}/_all_docs?Fields=_id,trashed,name,size,type,cozyMetadata&DesignDocs=false&include_docs=true`\n    )\n  return resp.rows\n}\n"
  },
  {
    "path": "src/lib/doctypes.js",
    "content": "import extraDoctypes from '@/lib/extraDoctypes'\nimport { Contact, Group } from '@/models'\n\nexport const DOCTYPE_FILES = 'io.cozy.files'\nexport const DOCTYPE_FILES_SETTINGS = 'io.cozy.files.settings'\nexport const DOCTYPE_DRIVE_SETTINGS = 'io.cozy.drive.settings'\nexport const DOCTYPE_FILES_ENCRYPTION = 'io.cozy.files.encryption'\nexport const DOCTYPE_FILES_SHORTCUT = 'io.cozy.files.shortcuts'\nexport const DOCTYPE_ALBUMS = 'io.cozy.photos.albums'\nexport const DOCTYPE_PHOTOS_SETTINGS = 'io.cozy.photos.settings'\nexport const DOCTYPE_APPS = 'io.cozy.apps'\nexport const DOCTYPE_CONTACTS = 'io.cozy.contacts'\nexport const DOCTYPE_KONNECTORS = 'io.cozy.konnectors'\nexport const NEXTCLOUD_MIGRATIONS_DOCTYPE = 'io.cozy.nextcloud.migrations'\nexport const DOCTYPE_CONTACTS_VERSION = 2\n\nexport const schema = {\n  files: {\n    doctype: DOCTYPE_FILES,\n    relationships: {\n      old_versions: {\n        type: 'has-many',\n        doctype: 'io.cozy.files.versions'\n      },\n      encryption: {\n        type: 'io.cozy.files:has-many',\n        doctype: DOCTYPE_FILES_ENCRYPTION\n      }\n    }\n  },\n  contacts: {\n    doctype: Contact.doctype,\n    doctypeVersion: DOCTYPE_CONTACTS_VERSION\n  },\n  groups: { doctype: Group.doctype },\n  versions: { doctype: 'io.cozy.files.versions' },\n  ...extraDoctypes\n}\n"
  },
  {
    "path": "src/lib/entries.js",
    "content": "/**\n * Get type from the entries\n * @param {IOCozyFile[]} entries - List of files moved\n * @returns {string} - Type from the entries\n */\nexport const getEntriesType = entries => {\n  const types = entries.reduce((acc, entry) => {\n    acc.add(entry.type)\n    return acc\n  }, new Set())\n\n  if (types.size === 1 && types.has('directory')) {\n    return 'directory'\n  }\n\n  if (types.size === 1 && types.has('file')) {\n    return 'file'\n  }\n\n  return 'element'\n}\n\n/**\n * Get translated type from the entries\n * @param {IOCozyFile[]} entries - List of files\n * @param {Function} t - Translation function\n * @returns {string} - Translated type from the entries\n */\nexport const getEntriesTypeTranslated = (t, entries) => {\n  const type = getEntriesType(entries)\n  return t(`EntriesType.${type}`, entries.length)\n}\n"
  },
  {
    "path": "src/lib/entries.spec.js",
    "content": "import { getEntriesType } from '@/lib/entries'\n\ndescribe('getEntriesType', () => {\n  it('should return file for entries only file', () => {\n    const res = getEntriesType([\n      { type: 'file' },\n      { type: 'file' },\n      { type: 'file' }\n    ])\n    expect(res).toBe('file')\n  })\n\n  it('should return folder for entries only folder', () => {\n    const res = getEntriesType([\n      { type: 'directory' },\n      { type: 'directory' },\n      { type: 'directory' }\n    ])\n    expect(res).toBe('directory')\n  })\n\n  it('should return element for entries with multiples types', () => {\n    const res = getEntriesType([\n      { type: 'file' },\n      { type: 'directory' },\n      { type: 'file' }\n    ])\n    expect(res).toBe('element')\n  })\n\n  it('should return element if something else from file or directory', () => {\n    const res = getEntriesType([\n      { type: 'something' },\n      { type: 'something' },\n      { type: 'something' }\n    ])\n    expect(res).toBe('element')\n  })\n})\n"
  },
  {
    "path": "src/lib/extraDoctypes.js",
    "content": "export default {}\n"
  },
  {
    "path": "src/lib/flags.js",
    "content": "import flag from 'cozy-flags'\n\nexport const initFlags = () => {\n  let activateFlags = flag('switcher') === true ? true : false\n\n  if (process.env.NODE_ENV !== 'production' && flag('switcher') === null) {\n    activateFlags = true\n  }\n\n  const searchParams = new URL(window.location).searchParams\n  if (!activateFlags && searchParams.get('flags') !== null) {\n    activateFlags = true\n  }\n\n  if (activateFlags) {\n    flagsList()\n  }\n}\n\n// flagName should use kebab case\nconst flagsList = () => {\n  flag('switcher', true)\n  flag('debug')\n  flag('drive.onlyoffice.editorToolbarHeight') // flagName should use kebab case\n  flag('drive.logger')\n  flag('drive.dacc-files-size-by-slug')\n  flag('drive.breadcrumb.showCompleteBreadcrumbOnPublicPage') // flagName should use kebab case\n  flag('drive.hide-nextcloud-dev')\n  flag('sharing.auto-open-settings.enabled')\n  flag('sharing.generate-link-button.enabled')\n}\n"
  },
  {
    "path": "src/lib/getFileMimetype.js",
    "content": "import mime from 'mime-types'\n\nconst getMimetypeFromFilename = name => {\n  return mime.lookup(name) || 'application/octet-stream'\n}\n\nconst mappingMimetypeSubtype = {\n  word: 'text',\n  text: 'text',\n  zip: 'zip',\n  pdf: 'pdf',\n  spreadsheet: 'sheet',\n  excel: 'sheet',\n  sheet: 'sheet',\n  presentation: 'slide',\n  powerpoint: 'slide'\n}\n\nexport const getFileMimetype =\n  collection =>\n  (mime = '', name = '') => {\n    const mimetype =\n      mime === 'application/octet-stream'\n        ? getMimetypeFromFilename(name.toLowerCase())\n        : mime\n    const [type, subtype] = mimetype.split('/')\n    if (collection[type]) {\n      return type\n    }\n    if (type === 'application') {\n      const existingType = subtype.match(\n        Object.keys(mappingMimetypeSubtype).join('|')\n      )\n      return existingType ? mappingMimetypeSubtype[existingType[0]] : undefined\n    }\n    return undefined\n  }\n"
  },
  {
    "path": "src/lib/getMimeTypeIcon.js",
    "content": "import get from 'lodash/get'\n\nimport IconAudio from 'cozy-ui/transpiled/react/Icons/FileTypeAudio'\nimport IconBin from 'cozy-ui/transpiled/react/Icons/FileTypeBin'\nimport IconCode from 'cozy-ui/transpiled/react/Icons/FileTypeCode'\nimport IconFiles from 'cozy-ui/transpiled/react/Icons/FileTypeFiles'\nimport IconFolder from 'cozy-ui/transpiled/react/Icons/FileTypeFolder'\nimport IconImage from 'cozy-ui/transpiled/react/Icons/FileTypeImage'\nimport IconNote from 'cozy-ui/transpiled/react/Icons/FileTypeNote'\nimport IconPdf from 'cozy-ui/transpiled/react/Icons/FileTypePdf'\nimport IconSheet from 'cozy-ui/transpiled/react/Icons/FileTypeSheet'\nimport IconSlide from 'cozy-ui/transpiled/react/Icons/FileTypeSlide'\nimport IconText from 'cozy-ui/transpiled/react/Icons/FileTypeText'\nimport IconVideo from 'cozy-ui/transpiled/react/Icons/FileTypeVideo'\nimport IconZip from 'cozy-ui/transpiled/react/Icons/FileTypeZip'\n\nimport IconDocs from '@/assets/icons/icon-docs.svg'\nimport { getFileMimetype } from '@/lib/getFileMimetype'\n\n/**\n * Returns the appropriate icon for a given file based on its mime type.\n *\n * @param {boolean} isDirectory\n * @param {string} name\n * @param {string} mime\n * @returns {import('react').ReactNode}\n */\nconst getMimeTypeIcon = (isDirectory, name, mime) => {\n  if (isDirectory) {\n    return IconFolder\n  } else if (/\\.cozy-note$/.test(name)) {\n    return IconNote\n  } else if (/\\.docs-note$/.test(name)) {\n    return IconDocs\n  } else {\n    const iconsByMimeType = {\n      audio: IconAudio,\n      bin: IconBin,\n      code: IconCode,\n      image: IconImage,\n      pdf: IconPdf,\n      slide: IconSlide,\n      sheet: IconSheet,\n      text: IconText,\n      video: IconVideo,\n      zip: IconZip\n    }\n    const type = getFileMimetype(iconsByMimeType)(mime, name)\n    return get(iconsByMimeType, type, IconFiles)\n  }\n}\n\nexport default getMimeTypeIcon\n"
  },
  {
    "path": "src/lib/konnectors.js",
    "content": "import { getReferencedBy } from 'cozy-client'\n\nimport { DOCTYPE_KONNECTORS } from '@/lib/doctypes'\n\n/**\n * Returns the slug of the konnector that produced the given file, or null\n * if the file is not referenced by any konnector.\n *\n * Konnector-created files carry an explicit `io.cozy.konnectors/<slug>`\n * entry in their `referenced_by` list. We read the first such reference\n * and strip the doctype prefix to recover the bare slug (e.g. \"edf\").\n *\n * `cozyMetadata.createdByApp` is intentionally not used: it is set by any\n * app or konnector that creates files (drive, notes, ...), so its value\n * can be an app slug that does not exist as a konnector and would 404\n * against `GET /konnectors/<slug>` on cozy-stack.\n *\n * @param {import('cozy-client/types/types').IOCozyFile} file - A file doc with its references hydrated.\n * @returns {string|null} The konnector slug, or null when the file has no konnector reference.\n */\nexport const getKonnectorSlugFromFile = file => {\n  const ref = getReferencedBy(file, DOCTYPE_KONNECTORS)[0]\n  return ref?.id?.replace(`${DOCTYPE_KONNECTORS}/`, '') ?? null\n}\n"
  },
  {
    "path": "src/lib/logger.js",
    "content": "import minilog from 'cozy-minilog'\n\nconst logger = minilog(`cozy-drive`)\nminilog.enable()\n\nminilog.suggest.allow(`cozy-drive`, 'log')\nminilog.suggest.allow(`cozy-drive`, 'info')\n\nexport default logger\n"
  },
  {
    "path": "src/lib/migration/qualification.js",
    "content": "import { get, has, isEmpty, omit, sortBy } from 'lodash'\n\nimport { models, Q } from 'cozy-client'\nimport log from 'cozy-logger'\n\nconst { Qualification } = models.document\nconst { saveFileQualification } = models.file\n\n/**\n * Query the files indexed on their updatedAt date.\n *\n * @param {object} client - The CozyClient instance\n * @param {string} date - The starting date to query\n * @param {number} limit - The maximum number of files to return\n */\nexport const queryFilesFromDate = async (client, date, limit) => {\n  const query = Q('io.cozy.files')\n    .where({\n      type: 'file',\n      'cozyMetadata.updatedAt': { $gt: date },\n      trashed: false\n    })\n    .indexFields(['type', 'cozyMetadata.updatedAt'])\n    .limitBy(limit)\n    .sortBy([{ type: 'asc' }, { 'cozyMetadata.updatedAt': 'asc' }])\n  return client.query(query)\n}\n\n/**\n * From a list of files, find the most recent updatedAt value\n *\n * @param {object} files - The unsorted files\n * @returns {string} The most recent updatedAt value\n */\nexport const getMostRecentUpdatedDate = files => {\n  const filesWithDate = files.filter(file =>\n    get(file, 'data.attributes.cozyMetadata.updatedAt')\n  )\n  const sortedFiles = sortBy(filesWithDate, [\n    'data.attributes.cozyMetadata.updatedAt'\n  ])\n  return sortedFiles.length > 0\n    ? get(\n        sortedFiles[sortedFiles.length - 1],\n        'data.attributes.cozyMetadata.updatedAt'\n      )\n    : null\n}\n\n/**\n * Extract the old qualification attributes from a file.\n *\n * @param {object} file - The file to extract old attributes from\n * @returns {object} The old qualification attributes\n */\nconst oldQualificationAttributes = file => {\n  const oldQualification = {}\n  Object.assign(\n    oldQualification,\n    has(file, 'metadata.id') ? { id: file.metadata.id } : null,\n    has(file, 'metadata.label') ? { label: file.metadata.label } : null,\n    has(file, 'metadata.classification')\n      ? { classification: file.metadata.classification }\n      : null,\n    has(file, 'metadata.subClassification')\n      ? { subClassification: file.metadata.subClassification }\n      : null,\n    has(file, 'metadata.categorie')\n      ? { categorie: file.metadata.categorie }\n      : null,\n    has(file, 'metadata.category')\n      ? { category: file.metadata.category }\n      : null,\n    has(file, 'metadata.categories')\n      ? { categories: file.metadata.categories }\n      : null,\n    has(file, 'metadata.subject') ? { subject: file.metadata.subject } : null,\n    has(file, 'metadata.subjects') ? { subjects: file.metadata.subjects } : null\n  )\n  return isEmpty(oldQualification) ? null : oldQualification\n}\n\n/**\n * Keep only the files with old qualification attributes\n *\n * @param {Array} files - The files to process\n * @returns {Array} The list of files having old qualification attributes\n */\nexport const extractFilesToMigrate = files => {\n  return files.filter(file => {\n    const oldAttributes = oldQualificationAttributes(file)\n    // This case can happen when a file was previously migrated, as we keep\n    // the id for retro-compatibility\n    if (has(oldAttributes, 'id') && !has(oldAttributes, 'label')) {\n      return false\n    }\n    return oldAttributes\n  })\n}\n\n/**\n * We changed some labels set by cozy-scanner: this method\n * transform them with the new one.\n *\n * @param {string} oldLabel - The old qualification label\n * @returns {string} The new qualification label\n */\nconst getNewLabelSetFromCozyScanner = oldLabel => {\n  if (oldLabel === 'registration') {\n    return 'vehicle_registration'\n  }\n  if (oldLabel === 'insurance_card') {\n    return 'national_health_insurance_card'\n  }\n  return oldLabel\n}\n\n/**\n * Remove the old qualification attributes from a file.\n *\n * @param {object} file - The file with old attributes\n * @returns {object} The file without the old attributes\n */\nexport const removeOldQualificationAttributes = file => {\n  const oldAttributes = oldQualificationAttributes(file)\n  // keep the id for retro-compatibility: it is used by cozy-scanner to display the label\n  if (has(oldAttributes, 'id')) {\n    delete oldAttributes.id\n  }\n  if (oldAttributes) {\n    const attributesPath = Object.keys(oldAttributes).map(oldAttribute => {\n      return `metadata.${oldAttribute}`\n    })\n    return omit(file, attributesPath)\n  }\n  return file\n}\n\n/**\n * Takes a file with an old qualification set by cozy-scanner and\n * returns the new qualification, by the label.\n *\n * @param {object} file - The file qualified by cozy-scanner\n * @returns {Qualification} The new qualification\n */\nconst getNewQualificationSetFromCozyScanner = file => {\n  const qualificationLabel = get(file, 'metadata.label')\n  const label = getNewLabelSetFromCozyScanner(qualificationLabel)\n  return Qualification.getByLabel(label)\n}\n\n/**\n * Takes a file with an old qualification set by a konnector and\n * returns the new qualification.\n * The qualification is fixed by a set of rules primarily based on the\n * contentAuthor and old attributes in certain cases.\n *\n * @param {object} file - The file qualified by a konnector\n * @returns {Qualification} The new qualification\n */\nconst getNewQualificationSetFromKonnector = file => {\n  const contentAuthor = get(file, 'metadata.contentAuthor')\n  const classification = get(file, 'metadata.classification')\n  const categories = get(file, 'metadata.categories')\n\n  // See https://github.com/konnectors/cozy-konnector-digiposte/blob/master/src/index.js\n  // See https://github.com/konnectors/orangeapi/blob/master/src/index.js\n  if (contentAuthor === 'orange') {\n    if (classification === 'invoicing') {\n      if (categories && categories.length > 0) {\n        if (categories[0] === 'phone') {\n          return Qualification.getByLabel('phone_invoice')\n        } else if (categories[0] === 'isp') {\n          return Qualification.getByLabel('telecom_invoice') // it might be both isp and phone\n        }\n      }\n    } else if (classification === 'payslip') {\n      return Qualification.getByLabel('pay_sheet')\n    }\n  }\n  // See https://github.com/konnectors/cozy-konnector-sncf/blob/master/src/index.js\n  else if (contentAuthor === 'sncf') {\n    return Qualification.getByLabel('transport_invoice')\n  }\n\n  // See https://github.com/konnectors/cozy-konnector-bouyguestelecom/blob/src/index.js\n  // See https://github.com/konnectors/cozy-konnector-bouyguesbox/blob/src/index.js\n  else if (contentAuthor === 'bouygues') {\n    return Qualification.getByLabel('telecom_invoice')\n  }\n\n  // See https://github.com/konnectors/cozy-konnector-free-mobile/blob/master/src/index.js\n  // See https://github.com/konnectors/cozy-konnector-free/blob/master/src/index.js\n  if (contentAuthor === 'free') {\n    if (categories && categories.length > 0) {\n      if (categories[0] === 'isp') {\n        return Qualification.getByLabel('isp_invoice')\n      } else if (categories[0] === 'phone') {\n        return Qualification.getByLabel('phone_invoice')\n      }\n    }\n  }\n\n  // See https://github.com/konnectors/edf/blob/master/src/index.js\n  if (contentAuthor === 'edf') {\n    return Qualification.getByLabel('energy_invoice')\n  }\n\n  // https://github.com/konnectors/cozy-konnector-ameli/blob/master/src/index.js\n  if (contentAuthor === 'ameli') {\n    return Qualification.getByLabel('health_invoice')\n  }\n\n  // https://github.com/konnectors/impots/blob/master/src/metadata.js\n  if (contentAuthor === 'impots.gouv') {\n    if (classification === 'tax_notice') {\n      return Qualification.getByLabel('tax_notice')\n    } else if (classification === 'tax_return') {\n      return Qualification.getByLabel('tax_return')\n    } else if (classification === 'tax_timetable') {\n      return Qualification.getByLabel('tax_timetable')\n    } else if (classification === 'mail') {\n      return Qualification.getByLabel('receipt')\n        .setSourceCategory('gov')\n        .setSourceSubCategory('tax')\n        .setSubjects(['tax'])\n    }\n  }\n  return null\n}\n\n/**\n * Get the new qualification from a file with old qualification attributes.\n *\n * @param {object} file - The file to requalify\n * @returns {object} The new qualification\n */\nexport const getFileRequalification = file => {\n  try {\n    const hasQualificationLabel = has(file, 'metadata.label')\n    // cozy-scanner stores the qualification label but konnectors don't\n    return hasQualificationLabel\n      ? getNewQualificationSetFromCozyScanner(file)\n      : getNewQualificationSetFromKonnector(file)\n  } catch (e) {\n    log('error', `The file cannot be migrated. ${e}`)\n    return null\n  }\n}\n\n/**\n * Migrate files by removing old qualification attributes and\n * setting the new qualification.\n *\n * @param {object} client - The CozyClient instance\n * @param {Array} files - The files to migrate\n * @returns {Array} The saved files\n */\nexport const migrateQualifiedFiles = async (client, files) => {\n  let updatedFiles = []\n  for (const file of files) {\n    const newQualification = getFileRequalification(file)\n    if (newQualification) {\n      const cleanedFile = removeOldQualificationAttributes(file)\n      const newFile = await saveFileQualification(\n        client,\n        cleanedFile,\n        newQualification\n      )\n      updatedFiles.push(newFile)\n    } else {\n      log('warn', `No migration case found for the file ${file._id}`)\n    }\n  }\n  return updatedFiles\n}\n"
  },
  {
    "path": "src/lib/migration/qualification.spec.js",
    "content": "import log from 'cozy-logger'\n\nimport {\n  extractFilesToMigrate,\n  getFileRequalification,\n  getMostRecentUpdatedDate,\n  removeOldQualificationAttributes\n} from '@/lib/migration/qualification'\n\njest.mock('cozy-logger', () => jest.fn())\n\ndescribe('qualification migration', () => {\n  it('should extract files to migrate based on qualification attributes', () => {\n    const fileNoQualif = {\n      metadata: {\n        datetime: '2020-01-01'\n      }\n    }\n    const fileFullQualif = {\n      metadata: {\n        id: '1',\n        label: 'dummy',\n        classification: 'dummy',\n        subClassification: 'dummy',\n        categorie: 'dummy',\n        category: 'dummy',\n        categories: ['dummies'],\n        subject: 'dummy',\n        subjects: ['dummy']\n      }\n    }\n    const files = [fileNoQualif, fileFullQualif]\n\n    const filesToMigrate = extractFilesToMigrate(files)\n    expect(filesToMigrate).toHaveLength(1)\n    expect(filesToMigrate[0]).toEqual(fileFullQualif)\n  })\n\n  it('should not extract files with id but not label attributes', () => {\n    const file = {\n      metadata: {\n        id: '1',\n        qualification: {}\n      }\n    }\n    expect(extractFilesToMigrate([file])).toHaveLength(0)\n  })\n\n  it('should get the new qualification for a file qualified by cozy-client', () => {\n    const file = {\n      metadata: {\n        id: '22',\n        classification: 'invoicing',\n        categorie: 'health',\n        label: 'health_invoice'\n      }\n    }\n    const qualif = getFileRequalification(file)\n    expect(qualif).toEqual({\n      icon: 'heart',\n      label: 'health_invoice',\n      purpose: 'invoice',\n      sourceCategory: 'health'\n    })\n  })\n\n  it('should get the new qualification for a file qualified by a konnector', () => {\n    const file = {\n      metadata: {\n        contentAuthor: 'ameli',\n        classification: 'invoicing',\n        categorie: 'health',\n        label: 'health_invoice'\n      }\n    }\n    const qualif = getFileRequalification(file)\n    expect(qualif).toEqual({\n      icon: 'heart',\n      label: 'health_invoice',\n      purpose: 'invoice',\n      sourceCategory: 'health'\n    })\n  })\n\n  it('should log an error null when no qualification is possible', () => {\n    const file = {\n      metadata: {\n        label: 'fake_label'\n      }\n    }\n    expect(getFileRequalification(file)).toBeNull()\n    expect(log).toHaveBeenCalledWith('error', expect.anything())\n  })\n\n  it('should remove old qualification attributes', () => {\n    const file = {\n      metadata: {\n        id: 1,\n        label: 'label',\n        classification: 'classification',\n        subClassification: 'subClassification',\n        categorie: 'categorie',\n        category: 'category',\n        categories: 'categories',\n        subject: 'subject',\n        subjects: 'subjects',\n        datetime: '2020-10-10'\n      }\n    }\n    expect(removeOldQualificationAttributes(file)).toEqual({\n      metadata: {\n        id: 1,\n        datetime: '2020-10-10'\n      }\n    })\n\n    file.metadata = {}\n    expect(removeOldQualificationAttributes(file)).toEqual(file)\n  })\n\n  it('should find the most recent date in a list of files', () => {\n    let files = []\n    expect(getMostRecentUpdatedDate(files)).toBeNull()\n\n    files = [{}, {}]\n    expect(getMostRecentUpdatedDate(files)).toBeNull()\n\n    files = [\n      {},\n      {\n        _id: '456',\n        data: {\n          attributes: {\n            cozyMetadata: {\n              updatedAt: '2020-01-01'\n            }\n          }\n        }\n      }\n    ]\n    expect(getMostRecentUpdatedDate(files)).toEqual('2020-01-01')\n\n    files = [\n      {},\n      {},\n      {\n        _id: '123',\n        data: {\n          attributes: {\n            cozyMetadata: {\n              updatedAt: '2020-01-01'\n            }\n          }\n        }\n      }\n    ]\n    expect(getMostRecentUpdatedDate(files)).toEqual('2020-01-01')\n\n    files = [\n      {\n        _id: '123',\n        data: {\n          attributes: {\n            cozyMetadata: {\n              updatedAt: '2020-01-02'\n            }\n          }\n        }\n      },\n      {},\n      {},\n      {\n        _id: '456',\n        data: {\n          attributes: {\n            cozyMetadata: {\n              updatedAt: '2020-01-01'\n            }\n          }\n        }\n      }\n    ]\n    expect(getMostRecentUpdatedDate(files)).toEqual('2020-01-02')\n  })\n})\n"
  },
  {
    "path": "src/lib/path.js",
    "content": "/**\n * Join two paths together ensuring there is only one slash between them\n * @param {string} start\n * @param {string} end\n * @returns\n */\nexport function joinPath(start, end) {\n  return `${start}${start.endsWith('/') ? '' : '/'}${end}`\n}\n\n/**\n * Get the parent folder path from a given path\n * @param {string} path The path to get the parent folder from\n * @returns {string|undefined} The path of the parent folder or undefined if the path is the root folder\n */\nexport const getParentPath = path => {\n  if (path === '/') return undefined\n  const parts = path.split('/')\n  parts.pop()\n  return parts.length === 1 ? '/' : parts.join('/')\n}\n"
  },
  {
    "path": "src/lib/path.spec.js",
    "content": "import { getParentPath } from './path'\n\nit('getParentPath', () => {\n  expect(getParentPath('/')).toBeUndefined()\n  expect(getParentPath('/folder1')).toEqual('/')\n  expect(getParentPath('/folder1/folder2/folder3')).toEqual('/folder1/folder2')\n  expect(getParentPath('/folder1/folder2/file1.png')).toEqual(\n    '/folder1/folder2'\n  )\n  expect(getParentPath('/folder1/folder2')).toEqual('/folder1')\n})\n"
  },
  {
    "path": "src/lib/queries.js",
    "content": "import { hasQueryBeenLoaded } from 'cozy-client'\n\n/**\n * Check if the query has been loaded and if it has data\n *\n * @param {import('cozy-client/types/types').UseQueryReturnValue} queryResult\n * @returns {boolean}\n */\nexport const hasDataLoaded = queryResult => {\n  return hasQueryBeenLoaded(queryResult) && queryResult.data\n}\n\nexport const parseFolderQueryId = maybeFolderQueryId => {\n  const splitted = maybeFolderQueryId.split(' ')\n  if (splitted.length !== 4) {\n    return null\n  }\n  return {\n    type: splitted[0],\n    folderId: splitted[1],\n    sortAttribute: splitted[2],\n    sortOrder: splitted[3]\n  }\n}\n\nexport const formatFolderQueryId = (\n  type,\n  folderId,\n  sortAttribute,\n  sortOrder,\n  driveId = ''\n) => {\n  return `${type} ${folderId} ${sortAttribute} ${sortOrder} ${driveId}`.trim()\n}\n\n/**\n * Get the query for folder if given the query for files\n * and vice versa.\n *\n * If given the queryId `directory id123 name desc`, will return\n * the query `files id123 name desc`.\n */\nexport const getMirrorQueryId = queryId => {\n  const { type, folderId, sortAttribute, sortOrder } =\n    parseFolderQueryId(queryId)\n  const otherType = type === 'directory' ? 'file' : 'directory'\n  const otherQueryId = formatFolderQueryId(\n    otherType,\n    folderId,\n    sortAttribute,\n    sortOrder\n  )\n  return otherQueryId\n}\n"
  },
  {
    "path": "src/lib/react-cozy-helpers/ModalManager.jsx",
    "content": "import React from 'react'\nimport { connect } from 'react-redux'\n\nconst SHOW_MODAL = 'SHOW_MODAL'\nconst HIDE_MODAL = 'HIDE_MODAL'\n\nconst reducer = (state = { show: false, component: null }, action) => {\n  switch (action.type) {\n    case SHOW_MODAL:\n      return { show: true, component: action.component }\n    case HIDE_MODAL:\n      return { show: false, component: null }\n    default:\n      return state\n  }\n}\n\nexport default reducer\n\nexport const showModal = component => ({\n  type: SHOW_MODAL,\n  component,\n  meta: {\n    hideActionMenu: true\n  }\n})\n\nconst hideModal = (meta = {}) => ({\n  type: HIDE_MODAL,\n  meta\n})\n\nexport const ModalManager = connect(state => ({\n  ...state.ui.modal\n}))(({ show, component, dispatch }) => {\n  if (!show) return null\n  return React.cloneElement(component, {\n    onClose: meta => dispatch(hideModal(meta))\n  })\n})\n"
  },
  {
    "path": "src/lib/react-cozy-helpers/QueryParameter.js",
    "content": "const arrToObj = (obj = {}, [key, val = true]) => {\n  obj[key] = decodeURIComponent(val)\n  return obj\n}\n\nconst getQueryParameter = () =>\n  window.location.search\n    .substring(1)\n    .split('&')\n    .map(varval => varval.split('='))\n    .reduce(arrToObj, {})\n\nexport default getQueryParameter\n"
  },
  {
    "path": "src/lib/react-cozy-helpers/QueryParameter.spec.js",
    "content": "import getQueryParameter from './QueryParameter'\n\ndescribe('getQueryParameter', () => {\n  afterEach(() => {\n    window.history.replaceState({}, '', '/')\n  })\n\n  it('should decode URI string', () => {\n    window.history.replaceState({}, '', '?username=N%C3%B6%C3%A9')\n    const { username } = getQueryParameter()\n\n    expect(username).toBe('Nöé')\n  })\n\n  it('should keep string with accent unchanged', () => {\n    window.history.replaceState({}, '', '?username=N%C3%B6%C3%A9')\n    const { username } = getQueryParameter()\n\n    expect(username).toBe('Nöé')\n  })\n\n  it('should not modify string with special characters', () => {\n    window.history.replaceState(\n      {},\n      '',\n      '?sharecode=eyJ_hbGc%2FiOiJ.S3mJz-B90iu.8D0%23JwCK'\n    )\n    const { sharecode } = getQueryParameter()\n\n    expect(sharecode).toBe('eyJ_hbGc/iOiJ.S3mJz-B90iu.8D0#JwCK')\n  })\n})\n"
  },
  {
    "path": "src/lib/react-cozy-helpers/index.js",
    "content": "import { combineReducers } from 'redux'\n\nimport modalReducer from './ModalManager'\n\nexport default combineReducers({ modal: modalReducer })\n\nexport { ModalManager, showModal } from './ModalManager'\n\nexport { default as getQueryParameter } from './QueryParameter'\n"
  },
  {
    "path": "src/lib/registerClientPlugins.js",
    "content": "import flag from 'cozy-flags'\nimport { RealtimePlugin } from 'cozy-realtime'\n\nconst registerClientPlugins = client => {\n  client.registerPlugin(RealtimePlugin)\n  client.registerPlugin(flag.plugin)\n}\n\nexport default registerClientPlugins\n"
  },
  {
    "path": "src/lib/sentry.js",
    "content": "import * as Sentry from '@sentry/react'\nimport { useEffect } from 'react'\nimport {\n  Routes,\n  useLocation,\n  useNavigationType,\n  createRoutesFromChildren,\n  matchRoutes\n} from 'react-router-dom'\n\nimport appMetadata from '@/lib/appMetadata'\n\nSentry.init({\n  dsn: 'https://05f3392b39bb4504a179c95aa5b0e8f6@errors.cozycloud.cc/41',\n  environment: process.env.NODE_ENV,\n  release: appMetadata.version,\n  integrations: [\n    // We also want to capture the `console.error` to, among other things,\n    // report the logs present in the `try/catch\n    Sentry.captureConsoleIntegration({ levels: ['error'] }),\n    Sentry.reactRouterV6BrowserTracingIntegration({\n      useEffect,\n      useLocation,\n      useNavigationType,\n      createRoutesFromChildren,\n      matchRoutes\n    })\n  ],\n  tracesSampleRate: 0.1,\n  // React log these warnings(bad Proptypes), in a console.error,\n  // it is not relevant to report this type of information to Sentry\n  ignoreErrors: [/^Warning: /]\n})\n\nexport const SentryRoutes = Sentry.withSentryReactRouterV6Routing(Routes)\n"
  },
  {
    "path": "src/locales/ar.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"القرص\",\n    \"item_recent\": \"الحديثة\",\n    \"item_activity\": \"النشاط\",\n    \"item_settings\": \"الإعدادات\",\n    \"btn-client-web\": \"تحصّل على كوزي\",\n    \"btn-client-mobile\": \"تحصّل على كوزي لجهازك المحمول !\",\n    \"link-client\": \"https://cozy.io/en/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.drive.mobile\",\n    \"link-client-ios\": \"https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"القرص\",\n    \"title_recent\": \"الحديثة\",\n    \"title_shared\": \"التي شاركتها\",\n    \"title_activity\": \"النشاط\"\n  },\n  \"Toolbar\": {\n    \"more\": \"المزيد\"\n  },\n  \"toolbar\": {\n    \"item_more\": \"المزيد\",\n    \"menu_select\": \"تحديد العناصر\",\n    \"menu_download_folder\": \"مُجلّد التنزيل\",\n    \"share\": \"شارك\",\n    \"select_all\": \"تحديد الكل\",\n    \"select_all_mobile\": \"الكل\",\n    \"clear_selection\": \"مسح التحديد\",\n    \"clear_selection_mobile\": \"مسح\",\n    \"delete_shared_drive\": \"حذف محرك الأقراص المشترك\",\n    \"sharings_tab_all\": \"الكل\",\n    \"sharings_tab_drives\": \"محركات الأقراص\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"cta\": \"شارك\",\n      \"details\": {\n        \"title\": \"تفاصيل المشاركة\"\n      },\n      \"sharedWithMe\": \"مُشارَك معي\",\n      \"shareByEmail\": {\n        \"subtitle\": \"عبر البريد الإلكتروني\",\n        \"email\": \"إلى :\",\n        \"send\": \"إرسل\"\n      },\n      \"sharingLink\": {\n        \"title\": \"رابط المشاركة\",\n        \"copy\": \"نسخ\",\n        \"copied\": \"تم نسخه\"\n      },\n      \"protectedShare\": {\n        \"title\": \"قريبًا !\"\n      },\n      \"close\": \"غلق\",\n      \"gettingLink\": \"جارٍ جلب رابطك …\"\n    }\n  },\n  \"table\": {\n    \"head_name\": \"الإسم\",\n    \"head_update\": \"آخر تحديث\",\n    \"head_size\": \"الحجم\",\n    \"row_size_symbols\": {\n      \"B\": \"ب\",\n      \"KB\": \"كب\",\n      \"MB\": \"مب\",\n      \"GB\": \"جب\",\n      \"TB\": \"تب\"\n    },\n    \"load_more\": \"عرض المزيد\"\n  },\n  \"Storage\": {\n    \"title\": \"التخزين\",\n    \"availability\": \"متاح %{smart_count} جيجابايت\",\n    \"increase\": \"زيادة مساحتك\"\n  },\n  \"SelectionBar\": {\n    \"share\": \"مشاركة\",\n    \"download\": \"تنزيل\",\n    \"trash\": \"حذف\",\n    \"rename\": \"تعديل التسمية\",\n    \"restore\": \"إسترجاع\",\n    \"close\": \"غلق\"\n  },\n  \"DeleteConfirm\": {\n    \"cancel\": \"إلغاء\",\n    \"delete\": \"حذف\"\n  },\n  \"emptytrashconfirmation\": {\n    \"cancel\": \"إلغاء\",\n    \"delete\": \"حذف الكل\"\n  },\n  \"DestroyConfirm\": {\n    \"cancel\": \"إلغاء\"\n  },\n  \"quotaalert\": {\n    \"confirm\": \"نعم\"\n  },\n  \"loading\": {\n    \"message\": \"تحميل\"\n  },\n  \"error\": {\n    \"download_file\": {\n      \"offline\": \"يتوجب أن تكون متصلا لتنزيل هذا الملف\",\n      \"missing\": \"إنّ الملف مفقود\"\n    }\n  },\n  \"alert\": {\n    \"could_not_open_file\": \"لقد تعذّر فتح هذا الملف\",\n    \"item_copied\": \"تم نسخ عنصر واحد\",\n    \"items_copied\": \"تم نسخ %{count} عنصر\",\n    \"item_cut\": \"تم قص عنصر واحد\",\n    \"items_cut\": \"تم قص %{count} عنصر\",\n    \"item_moved\": \"تم نقل عنصر واحد\",\n    \"items_moved\": \"تم نقل %{count} عنصر\",\n    \"item_pasted\": \"تم نقل عنصر واحد\",\n    \"items_pasted\": \"تم نقل %{count} عنصر\",\n    \"copy_files_only\": \"لا يمكن نسخ المجلدات\",\n    \"copy_not_allowed\": \"عملية النسخ غير مسموحة في هذا العرض.\",\n    \"cut_not_allowed\": \"عملية القص غير مسموحة في هذا العرض.\",\n    \"paste_error\": \"حدث خطأ أثناء لصق الملفات\",\n    \"paste_failed\": \"فشل في لصق الملفات\",\n    \"paste_sharing_error\": \"لا يمكن لصق الملفات بسبب قيود المشاركة. يرجى استخدام إجراء النقل بدلاً من ذلك.\",\n    \"paste_same_folder_skipped\": \"لا يمكن نقل العناصر إلى نفس المجلد الذي توجد فيه بالفعل.\",\n    \"paste_not_allowed\": \"لا يمكنك اللصق في هذا المجلد\",\n    \"cannot_move_shared_drive\": \"لا يمكنك نقل مجلد القرص المشترك\",\n    \"cannot_copy_shared_drive\": \"لا يمكنك نسخ مجلد محرك الأقراص المشترك\"\n  },\n  \"UploadQueue\": {\n    \"close\": \"غلق\",\n    \"item\": {\n      \"pending\": \"معلق\"\n    }\n  },\n  \"Viewer\": {\n    \"close\": \"إغلاق\",\n    \"noviewer\": {\n      \"download\": \"نزِّل هذا الملف\"\n    },\n    \"actions\": {\n      \"download\": \"تنزيل\"\n    },\n    \"loading\": {\n      \"retry\": \"إعادة المحاولة\"\n    }\n  },\n  \"actions\": {\n    \"details\": \"تفاصيل\",\n    \"personalizeFolder\": {\n      \"label\": \"تخصيص المجلد\"\n    },\n    \"summariseByAI\": \"تلخيص\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"تخصيص المجلد\",\n    \"description\": \"اختر لونًا محددًا لمجلدك\",\n    \"cancel\": \"إلغاء\",\n    \"apply\": \"تطبيق\",\n    \"error\": \"حدث خطأ، يرجى المحاولة مرة أخرى.\",\n    \"tabs\": {\n      \"colors\": \"الألوان\",\n      \"icons\": \"الأيقونات\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"المستخدمة مؤخراً\",\n      \"chooseCustomIcon\": \"اختر أيقونة مخصصة\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/locales/de.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"Laufwerk\",\n    \"item_recent\": \"Zuletzt\",\n    \"item_sharings\": \"Freigaben\",\n    \"item_shared\": \"Von mir geteilt\",\n    \"item_activity\": \"Aktivität\",\n    \"item_trash\": \"Papierkorb\",\n    \"item_settings\": \"Einstellungen\",\n    \"item_collect\": \"Verwaltung\",\n    \"btn-client\": \"Hol' dir Twake für den Desktop!\",\n    \"btn-client-web\": \"Hol' dir Twake!\",\n    \"btn-client-mobile\": \"Hol' dir %{name} auf dein Handy!\",\n    \"banner-txt-client\": \"Hol' dir %{name} für den Desktop und synchronisiere deine Dateien sicher, um jederzeit auf sie zuzugreifen.\",\n    \"banner-btn-client\": \"Herunterladen\",\n    \"link-client\": \"https://cozy.io/en/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.drive.mobile\",\n    \"link-client-ios\": \"https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8\",\n    \"link-client-web\": \"https://cozy.io/try-it\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"Laufwerk\",\n    \"title_recent\": \"Neueste\",\n    \"title_sharings\": \"Freigaben\",\n    \"title_shared\": \"Von mir geteilt\",\n    \"title_activity\": \"Aktivität\",\n    \"title_trash\": \"Papierkorb\"\n  },\n  \"Toolbar\": {\n    \"more\": \"Mehr\"\n  },\n  \"toolbar\": {\n    \"menu_upload\": \"Dateien hochladen\",\n    \"item_more\": \"Mehr\",\n    \"menu_new_folder\": \"Ordner\",\n    \"menu_select\": \"Elemente auswählen\",\n    \"menu_share_folder\": \"Ordner freigeben\",\n    \"menu_download\": \"Herunterladen\",\n    \"menu_sync_cozy\": \"In mein Twake synchronisieren\",\n    \"add_to_mine\": \"Meinem Twake hinzufügen\",\n    \"menu_download_folder\": \"Ordner herunterladen\",\n    \"menu_download_file\": \"Diese Datei herunterladen\",\n    \"menu_create_note\": \"Notizen\",\n    \"menu_create_shortcut\": \"Abkürzung\",\n    \"empty_trash\": \"Papierkorb leeren\",\n    \"share\": \"Freigeben\",\n    \"trash\": \"Entfernen\",\n    \"delete_shared_drive\": \"Gemeinsames Laufwerk löschen\",\n    \"leave\": \"Geteilten Ordner verlassen & löschen\",\n    \"menu_add\": \"Hinzufügen\",\n    \"menu_create\": \"Erstellen\",\n    \"menu_onlyOffice\": {\n      \"text\": \"Textdokument\",\n      \"spreadsheet\": \"Tabellenkalkulation\",\n      \"slide\": \"Präsentation\"\n    },\n    \"select_all\": \"Alle auswählen\",\n    \"clear_selection\": \"Auswahl aufheben\",\n    \"sharings_tab_all\": \"Alle\",\n    \"sharings_tab_drives\": \"Laufwerke\"\n  },\n  \"Share\": {\n    \"create-cozy\": \"Meinen Twake erstellen\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"cta\": \"Freigeben\",\n      \"title\": \"Freigeben\",\n      \"details\": {\n        \"title\": \"Details freigeben\",\n        \"createdAt\": \"Am %{date}\",\n        \"ro\": \"Kann lesen\",\n        \"rw\": \"Darf ändern\",\n        \"desc\": {\n          \"ro\": \"Du kannst diesen Inhalt sehen, herunterladen und deinem Twake hinzufügen. Du wirst Aktualisierungen des Besitzers erhalten, selbst jedoch keine Änderungen vornehmen können.\",\n          \"rw\": \"Du kannst diesen Inhalt sehen, ändern, löschen und deinem Twake hinzufügen. Deine Änderungen sind auf anderen Cozies sichtbar.\"\n        }\n      },\n      \"sharedByMe\": \"Von mir geteilt\",\n      \"sharedWithMe\": \"Mit mir geteilt\",\n      \"sharedBy\": \"Geteilt von %{name}\",\n      \"shareByLink\": {\n        \"subtitle\": \"Über öffentlichen Link\",\n        \"desc\": \"Jeder der den Link kennt, kann deine Dateien sehen und herunterladen.\",\n        \"creating\": \"Erstellen deines Links...\",\n        \"copy\": \"Link kopieren\",\n        \"copied\": \"Der Link wurde in deine Zwischenablage kopiert\",\n        \"failed\": \"Unfähig in deine Zwischenablage zu kopieren\"\n      },\n      \"shareByEmail\": {\n        \"subtitle\": \"Per E-Mail\",\n        \"email\": \"An:\",\n        \"emailPlaceholder\": \"Gib die E-Mail-Adresse oder den Namen des Empfängers ein\",\n        \"send\": \"Senden\",\n        \"genericSuccess\": \"Du hast eine Einladung an %{count} Kontakte gesendet.\",\n        \"success\": \"Du hast eine Einladung an %{email} gesendet.\",\n        \"comingsoon\": \"Bald verfügbar: Du wirst mit nur einem Klick Fotos und Dokumente deiner Familie, deinen Freunden und sogar deinen Kollegen freigeben können. Keine Sorge, wir benachrichtigen dich sobald es soweit ist!\",\n        \"onlyByLink\": \"Dieser %{type} kann nur als Link geteilt werden, da\",\n        \"type\": {\n          \"file\": \"Datei\",\n          \"folder\": \"Ordner\"\n        },\n        \"hasSharedParent\": \"hat einen geteilten Ursprung\",\n        \"hasSharedChild\": \"enthält ein geteiltes Element\"\n      },\n      \"revoke\": {\n        \"title\": \"Freigabe aufheben\",\n        \"desc\": \"Dieser Kontakt behält eine Kopie. Änderungen werden jedoch nicht mehr synchronisiert.\",\n        \"success\": \"Du hast diese geteilte Datei von %{email} entfernt.\"\n      },\n      \"revokeSelf\": {\n        \"title\": \"Entferne mich von der Freigabe\",\n        \"desc\": \"Du behältst den Inhalt. Er wird aber nicht mehr in deinem Twake erneuert.\",\n        \"success\": \"Du wurdest von dieser Freigabe entfernt.\"\n      },\n      \"sharingLink\": {\n        \"title\": \"Link zum Freigeben\",\n        \"copy\": \"Kopieren\",\n        \"copied\": \"Kopiert\"\n      },\n      \"whoHasAccess\": {\n        \"title\": \"1 Person hat Zugriff |||| %{smart_count} Personen haben Zugriff\"\n      },\n      \"protectedShare\": {\n        \"title\": \"Bald verfügbar!\",\n        \"desc\": \"Teile alles per E-Mail mit deiner Familie und Freunden!\"\n      },\n      \"close\": \"Schließen\",\n      \"gettingLink\": \"Erstelle deinen Link ...\",\n      \"error\": {\n        \"generic\": \"Beim Erstellen des Dateifreigabelinks ist ein Fehler aufgetreten, bitte versuche es erneut.\",\n        \"revoke\": \"Hoppla, das hat nicht geklappt. Bitte kontaktiere uns, damit wir den Fehler so schnell wie möglich beheben können.\"\n      },\n      \"specialCase\": {\n        \"base\": \"Dieser %{type} kann nur als Link geteilt werden, da\",\n        \"isInSharedFolder\": \"ist in einem geteilten Ordner\",\n        \"hasSharedFolder\": \"enthält einen geteilten Ordner\"\n      }\n    },\n    \"viewer-fallback\": \"Sobald der Download begonnen hat, kannst du mich schließen.\",\n    \"dropzone\": {\n      \"teaser\": \"Ziehe Dateien hierher um sie hochzuladen:\",\n      \"noFolderSupport\": \"Ordner drag&drop wird von deinem Browser derzeit nicht unterstützt. Lade deine Dateien bitte manuell hoch.\"\n    }\n  },\n  \"table\": {\n    \"head_name\": \"Name\",\n    \"head_update\": \"Letzte Änderung\",\n    \"head_size\": \"Größe\",\n    \"head_status\": \"Teilen\",\n    \"head_thumbnail_size\": \"Wechsele die Größe des Vorschaubildes\",\n    \"row_update_format\": \"dd.LL.yyyy\",\n    \"row_update_format_full\": \"dd.LL.yyyy\",\n    \"row_read_only\": \"Freigeben (nur Lesen)\",\n    \"row_read_write\": \"Freigeben (Lesen & Schreiben)\",\n    \"row_size_symbols\": {\n      \"B\": \"Byte\",\n      \"KB\": \"Kilobyte\",\n      \"MB\": \"Megabyte\",\n      \"GB\": \"Gigabyte\",\n      \"TB\": \"Terabyte\",\n      \"PB\": \"Petabyte\",\n      \"EB\": \"Exabyte\",\n      \"ZB\": \"Zettabyte\",\n      \"YB\": \"Yottabyte\"\n    },\n    \"load_more\": \"Mehr laden\",\n    \"mobile\": {\n      \"head_name_asc\": \"A-Z\",\n      \"head_name_desc\": \"Z-A\",\n      \"head_updated_at_asc\": \"Älteste zuerst\",\n      \"head_updated_at_desc\": \"Neueste zuerst\",\n      \"head_size_asc\": \"Kleinste zuerst\",\n      \"head_size_desc\": \"Größte zuerst\"\n    },\n    \"tooltip\": {\n      \"carbonCopy\": {\n        \"title\": \"Durchschlag\",\n        \"caption\": \"Zeigt an, ob das Dokument von Twake Workplace, dem Host Ihrer Twake, als \\\"authentisch und original\\\" definiert wird, da es behaupten kann, dass es direkt von einem Drittanbieterdienst stammt, ohne dass es verändert wurde.\"\n      },\n      \"electronicSafe\": {\n        \"title\": \"Elektronischer Tresor\",\n        \"caption\": \"Gibt an, ob das Originaldokument in Ihrem persönlichen digitalen Tresor mit den Zertifizierungen, die ihm Beweiskraft verleihen, und einer 50-jährigen Aufbewahrungsgarantie über die Hinterlegung hinaus gesichert ist.\"\n      }\n    }\n  },\n  \"Storage\": {\n    \"title\": \"Speicher\",\n    \"availability\": \"%{smart_count} GB verfügbar\",\n    \"increase\": \"Speicherplatz erweitern\"\n  },\n  \"SelectionBar\": {\n    \"selected_count\": \"Element ausgewählt |||| Elemente ausgewählt\",\n    \"share\": \"Freigeben\",\n    \"download\": \"Herunterladen\",\n    \"trash\": \"Entfernen\",\n    \"destroy\": \"Dauerhaft löschen\",\n    \"rename\": \"Umbenennen\",\n    \"restore\": \"Wiederherstellen\",\n    \"close\": \"Schließen\",\n    \"openWith\": \"Öffnen mit...\",\n    \"applePreview\": \"Apple Vorschau\",\n    \"forward\": \"Weiterleiten\",\n    \"forwardTo\": \"Weiterleiten an...\",\n    \"moveto\": \"Verschieben nach...\",\n    \"moveto_mobile\": \"Verschieben\",\n    \"phone-download\": \"Offline verfügbar machen\",\n    \"qualify\": \"Kategorisieren\",\n    \"history\": \"Verlauf\"\n  },\n  \"DeleteConfirm\": {\n    \"title\": \"Dieses Element löschen? |||| Diese Elemente löschen?\",\n    \"trash\": \"Es wird in den Papierkorb verschoben. |||| Sie werden in den Papierkorb verschoben.\",\n    \"restore\": \"Du kannst es jederzeit wiederherstellen. |||| Du kannst sie jederzeit wiederherstellen.\",\n    \"link\": \"Link Freigabe wird nicht länger aktiv sein\",\n    \"referenced\": \"Einige der Dateien innerhalb der Auswahl beziehen sich auf ein Fotoalbum. Sie werden aus ihm entfernt, wenn du sie in den Müll verschiebst.\",\n    \"cancel\": \"Abbrechen\",\n    \"delete\": \"Entfernen\"\n  },\n  \"emptytrashconfirmation\": {\n    \"title\": \" Dauerhaft löschen? \",\n    \"forbidden\": \"Du kannst nicht mehr auf diese Dateien zugreifen.\",\n    \"restore\": \"Du kannst diese Dateien nicht wiederherstellen, wenn du keine Sicherung gemacht hast.\",\n    \"cancel\": \"Abbrechen\",\n    \"delete\": \"Alles löschen\"\n  },\n  \"DestroyConfirm\": {\n    \"title\": \"Dauerhaft löschen?\",\n    \"forbidden\": \"Du kannst nicht mehr auf diese Datei zugreifen. |||| Du kannst nicht mehr auf diese Dateien zugreifen.\",\n    \"restore\": \"Du kannst diese Datei nicht wiederherstellen, wenn du keine Sicherung gemacht hast. |||| Du kannst diese Dateien nicht wiederherstellen, wenn du keine Sicherung gemacht hast.\",\n    \"cancel\": \"Abbrechen\",\n    \"delete\": \"Dauerhaft löschen\"\n  },\n  \"quotaalert\": {\n    \"title\": \"Dein Speicherplatz ist voll :(\",\n    \"desc\": \"Bitte entferne Dateien, leere deinen Mülleiemer oder erhöhe dein Speicherkontingent bevor du wieder Dateien hochlädtst.\",\n    \"confirm\": \"OK\",\n    \"increase\": \"Erhöhe dein Speicherkontingent\"\n  },\n  \"loading\": {\n    \"message\": \"Lädt\",\n    \"onlyOfficeCreateInProgress\": \"Erstellen der aktuellen Datei...\"\n  },\n  \"empty\": {\n    \"title\": \"Du hast keine Dateien in diesem Ordner.\",\n    \"text\": \"Wählen Sie Dateien auf Ihrem Computer aus oder ziehen Sie sie hierher.\",\n    \"mobile_text\": \"Wählen Sie Dateien auf Ihrem Gerät aus.\",\n    \"trash_title\": \"Du hast keine gelöschten Dateien.\",\n    \"trash_text\": \"Verschiebe Dateien, die du nicht länger benötigst in den Papierkorb und lösche Elemente dauerhaft, um Speicherplatz freizumachen.\"\n  },\n  \"error\": {\n    \"open_folder\": \"Beim Öffnen des Ordners ist etwas schief gelaufen.\",\n    \"open_file\": \"Beim Öffnen der Datei ist etwas schief gelaufen.\",\n    \"button\": {\n      \"reload\": \"Jetzt aktualisieren\"\n    },\n    \"download_file\": {\n      \"offline\": \"Du solltest verbunden sein, um diese Datei herunterzuladen.\",\n      \"missing\": \"Diese Datei fehlt\"\n    }\n  },\n  \"Error\": {\n    \"public_unshared_title\": \"Entschuldige, dieser Links ist nicht länger verfügbar.\",\n    \"public_unshared_text\": \"Dieser Link ist abgelaufen oder vom Besitzer entfernt worden. Lass' es ihn wissen, dass du ihn verpasst hast.\",\n    \"generic\": \"Etwas ist schiefgelaufen. Warte ein paar Minuten und versuche es erneut.\"\n  },\n  \"alert\": {\n    \"could_not_open_file\": \"Diese Datei konnte nicht geöffnet werden\",\n    \"try_again\": \"Ein Fehler ist aufgetreten, bitte versuche es gleich noch einmal.\",\n    \"restore_file_success\": \"Die Auswahl wurde erfolgreich wiederhergestellt.\",\n    \"trash_file_success\": \"Die Auswahl wurde in den Papierkorb verschoben.\",\n    \"destroy_file_success\": \"Die Auswahl wurde endgültig gelöscht.\",\n    \"empty_trash_progress\": \"Dein Papierkorb wird entleert. Dies kann einen Augenblick dauern.\",\n    \"empty_trash_success\": \"Der Papierkorb wurde entleert.\",\n    \"folder_name\": \"Das Element %{folderName} existiert bereits, bitte wähle einen neuen Namen.\",\n    \"file_name\": \"Das Element %{fileName} existiert bereits, bitte wähle einen neuen Namen.\",\n    \"file_name_missing\": \"Der Dateiname ist falsch, bitte geben Sie einen neuen Namen ein.\",\n    \"file_name_illegal_name\": \"Der Name %{fileName} ist ungültig, bitte wählen Sie einen neuen Namen.\",\n    \"file_name_illegal_characters\": \"Das Element %{fileName} enthält ungültige Zeichen: %{characters}\",\n    \"folder_generic\": \" Ein Fehler ist aufgetreten, bitte versuche es noch einmal.\",\n    \"folder_abort\": \"Du musst deinem neuen Ordner einen Namen hinzufügen, wenn du ihn speichern möchtest. Deine Daten wurden nicht gespeichert.\",\n    \"offline\": \"Diese Funktion ist offline nicht verfügbar.\",\n    \"preparing\": \"Deine Dateien werden vorbereitet...\",\n    \"item_copied\": \"1 Element kopiert\",\n    \"items_copied\": \"%{count} Elemente kopiert\",\n    \"item_cut\": \"1 Element ausgeschnitten\",\n    \"items_cut\": \"%{count} Elemente ausgeschnitten\",\n    \"item_moved\": \"1 Element wurde verschoben\",\n    \"items_moved\": \"%{count} Elemente wurden verschoben\",\n    \"item_pasted\": \"1 Element wurde verschoben\",\n    \"items_pasted\": \"%{count} Elemente wurden verschoben\",\n    \"copy_files_only\": \"Ordner können nicht kopiert werden\",\n    \"copy_not_allowed\": \"Der Kopiervorgang ist in dieser Ansicht nicht erlaubt.\",\n    \"cut_not_allowed\": \"Der Ausschneiden-Vorgang ist in dieser Ansicht nicht erlaubt.\",\n    \"paste_error\": \"Beim Einfügen der Dateien ist ein Fehler aufgetreten\",\n    \"paste_failed\": \"Einfügen der Dateien fehlgeschlagen\",\n    \"paste_sharing_error\": \"Dateien können aufgrund von Freigabebeschränkungen nicht eingefügt werden. Bitte verwenden Sie stattdessen die Verschieben-Aktion.\",\n    \"paste_same_folder_skipped\": \"Elemente können nicht in denselben Ordner verschoben werden, in dem sie sich bereits befinden.\",\n    \"paste_not_allowed\": \"Sie können nicht in diesen Ordner einfügen\",\n    \"cannot_move_shared_drive\": \"Sie können den freigegebenen Laufwerksordner nicht verschieben\",\n    \"cannot_copy_shared_drive\": \"Du kannst keinen freigegebenen Laufwerksordner kopieren\"\n  },\n  \"upload\": {\n    \"label\": \"Hochladen\",\n    \"alert\": {\n      \"network\": \"Du bist zurzeit offline. Bitte versuche es erneut, sobald du wieder verbunden bist.\"\n    }\n  },\n  \"intents\": {\n    \"alert\": {\n      \"error\": \"Unfähig, die Datei automatisch hochzuladen, bitte lade sie manuell über das Hochlademenü hoch.\"\n    },\n    \"picker\": {\n      \"select\": \"Auswählen\",\n      \"cancel\": \"Abbrechen\",\n      \"new_folder\": \"Neuer Ordner\",\n      \"instructions\": \"Wähle ein Ziel\"\n    }\n  },\n  \"UploadQueue\": {\n    \"header\": \"Hochladen von %{smart_count} Foto in dein Twake Drive |||| Hochladen von %{smart_count} Fotos in dein Twake Drive\",\n    \"header_mobile\": \"Hochladen %{done} von %{total}\",\n    \"header_done\": \"Hochladen %{done} aus %{total} erfolgreich\",\n    \"close\": \"Schließen\",\n    \"item\": {\n      \"pending\": \"Ausstehend\"\n    }\n  },\n  \"Viewer\": {\n    \"close\": \"Schließen\",\n    \"noviewer\": {\n      \"download\": \"Diese Datei herunterladen\",\n      \"openWith\": \"Öffnen mit...\",\n      \"openInOnlyOffice\": \"Öffnen mit Only Office\",\n      \"cta\": {\n        \"saveTime\": \"Spare etwas Zeit!\",\n        \"installDesktop\": \"Installiere das Synchronisationstool für deinen Computer\",\n        \"accessFiles\": \"Greife direkt von deinem Computer auf deine Datein zu\"\n      }\n    },\n    \"actions\": {\n      \"download\": \"Herunterladen\",\n      \"forward\": \"Weiterleiten\"\n    },\n    \"loading\": {\n      \"error\": \"Diese Datei konnte nicht geladen werden. Hast du eine funktionierende Internetverbindung?\",\n      \"retry\": \"Wiederholen\"\n    },\n    \"error\": {\n      \"noapp\": \"Keine Anwendung auf Ihrem Gerät kann diese Datei verarbeiten.\",\n      \"generic\": \"Ein Fehler ist beim Öffnen dieser Datei aufgetreten, bitte versuche es erneut.\",\n      \"noNetwork\": \"Du bist derzeit offline.\"\n    },\n    \"panel\": {\n      \"title\": \"Nützliche Informationen\"\n    }\n  },\n  \"Move\": {\n    \"to\": \"Verschiebe zu:\",\n    \"action\": \"Verschieben\",\n    \"cancel\": \"Abbrechen\",\n    \"modalTitle\": \"Verschieben\",\n    \"title\": \"%{smart_count} Element |||| %{smart_count} Elemente\",\n    \"success\": \"%{subject} wurde in %{target} verschoben. |||| %{smart_count} Elemente wurden in %{target} verschoben.\",\n    \"error\": \"Etwas ist beim Verschieben dieses Elements schiefgelaufen, bitte versuche es später erneut. |||| Etwas ist beim Verschieben dieser Elemente schiefgelaufen, bitte versuche es später erneut.\",\n    \"cancelled\": \"%{subject} wurde zurück an seinen Ursprungsort geschoben. |||| %{smart_count} Elemente wurden zurück an ihren Ursprungsort geschoben.\",\n    \"cancelledWithRestoreErrors\": \"%{subject} wurde zurück an seinen Ursprungsort geschoben, aber es gab einen Fehler beim Wiederherstellen der Datei aus dem Papierkorb. |||| %{smart_count} Elemente wurden zurück an ihren Ursprungsort geschoben, aber es gab %{restoreErrorsCount} Fehler beim Wiederherstellen der Datei(en) aus dem Papierkorb.\",\n    \"cancelled_error\": \"Entschuldige, es gab einen Fehler beim Zurückschieben dieses Elements. |||| Entschuldige, es gab einen Fehler beim Zurückschieben dieser Elemente.\"\n  },\n  \"ImportToDrive\": {\n    \"title\": \"%{smart_count} Element |||| %{smart_count} Elemente\",\n    \"to\": \"Speichern in:\",\n    \"action\": \"Speichern\",\n    \"cancel\": \"Abbrechen\",\n    \"success\": \"%{smart_count} gesicherte Datei |||| %{smart_count} gesicherte Dateien\",\n    \"error\": \"Etwas ist schiefgelaufen. Bitte versuche es erneut\"\n  },\n  \"FileOpenerExternal\": {\n    \"fileNotFoundError\": \"Fehler: Datei nicht gefunden\"\n  },\n  \"TOS\": {\n    \"updated\": {\n      \"title\": \"GDPR wird Realität!\",\n      \"detail\": \"Im Rahmen der General Data Protection Regulation (GDPR), [wurden unsere Nutzungsbedingungen aktualisiert](%{link}) und werden ab dem 25. März 2018 auf alle unsere Nutzer angewandt.\",\n      \"cta\": \"TOS akzeptieren und fortfahren\",\n      \"disconnect\": \"Ablehnen und trennen\",\n      \"error\": \"Etwas ist schiefgelaufen. Bitte versuche es später erneut\"\n    }\n  },\n  \"manifest\": {\n    \"permissions\": {\n      \"contacts\": {\n        \"description\": \"Erforderlich, um deinen Kontakten Dateien freizugeben\"\n      },\n      \"groups\": {\n        \"description\": \"Erforderlich, um deinen Gruppen Dateien freizugeben\"\n      }\n    }\n  },\n  \"models\": {\n    \"contact\": {\n      \"defaultDisplayName\": \"Anonym\"\n    }\n  },\n  \"Scan\": {\n    \"scan_a_doc\": \"Scanne ein Dokument\",\n    \"save_doc\": \"Speichere das Dokument\",\n    \"filename\": \"Dateiname\",\n    \"save\": \"Speichern\",\n    \"cancel\": \"Abbrechen\",\n    \"qualify\": \"Kategorisieren\",\n    \"apply\": \"Anwenden\",\n    \"error\": {\n      \"offline\": \"Du bist derzeit offline und kannst diese Funktion nicht nutzen. Versuche es später erneut\",\n      \"uploading\": \"Du lädst bereits eine Datei hoch. Warte bis zur Fertigstellung und versuche es erneut.\",\n      \"generic\": \"Etwas ist schiefgelaufen. Bitte versuche es erneut\"\n    },\n    \"successful\": {\n      \"qualified_ok\": \"Du hast die Datei erfolgreich kategorisiert!\"\n    }\n  },\n  \"History\": {\n    \"description\": \"Die letzten 20 Versionen deiner Dateien werden automatisch behalten. Wähle eine Version aus, um sie herunterzuladen.\",\n    \"current_version\": \"Aktuelle Version\",\n    \"loading\": \"Lädt...\",\n    \"noFileVersionEnabled\": \"Dein Twake wird bald dazu in der Lage sein, deine letzten Dateiänderungen zu archivieren, um einem Verlust vorzubeugen\"\n  },\n  \"External\": {\n    \"redirection\": {\n      \"title\": \"Weiterleitung\",\n      \"text\": \"Du wirst gleich weitergeleitet...\",\n      \"error\": \"Fehler während der Weiterleitung. Im Allgemeinen deutet dies auf ein falsches Format deines Inhalts hin.\"\n    }\n  },\n  \"RenameModal\": {\n    \"title\": \"Umbenennen\",\n    \"description\": \"Du bist dabei, die Dateiendung zu ändern. Möchtest du fortfahren?\",\n    \"continue\": \"Fortsetzen\",\n    \"cancel\": \"Abbrechen\"\n  },\n  \"Shortcut\": {\n    \"title_modal\": \"Erstelle eine Verknüpfung\",\n    \"filename\": \"Dateiname\",\n    \"url\": \"URL\",\n    \"cancel\": \"Abbrechen\",\n    \"create\": \"Erstellen\",\n    \"created\": \"Deine Verknüpfung wurde erstellt\",\n    \"errored\": \"Ein Fehler ist aufgetreten\",\n    \"filename_error_ends\": \"Der Name sollte mit .url enden\",\n    \"needs_info\": \"Die Verknüpfung benötigt mindestens eine URL und einen Dateinamen\",\n    \"url_badformat\": \"Deine URL hat nicht das richtige Format\"\n  },\n  \"OnlyOffice\": {\n    \"Error\": {\n      \"title\": \"Etwas geht schief\",\n      \"text\": \"Bitte versuchen Sie, die Seite neu zu laden\"\n    },\n    \"readOnly\": {\n      \"title\": \"Nur lesen\",\n      \"tooltip\": \"Sie sind nur berechtigt, dieses Dokument anzusehen. Kontaktieren Sie den Eigentümer, um Schreibrechte zu erhalten.\"\n    },\n    \"createFileName\": {\n      \"text\": \"Neues Textdokument\",\n      \"spreadsheet\": \" Neue Tabellenkalkulation\",\n      \"slide\": \"Neue Präsentation\"\n    }\n  },\n  \"Migration\": {\n    \"title\": \"Aktualisierte Twake Drive\",\n    \"content\": \"Twake Drive muss aktualisiert werden, um seine Leistung zu verbessern. Dies kann bis zu mehreren Minuten dauern, während derer Sie Ihre App nicht nutzen können. Möchten Sie es jetzt tun? Wenn Sie sich weigern, werden wir Sie beim nächsten Mal wieder fragen\",\n    \"confirm\": \"Okay, los geht's!\",\n    \"cancel\": \"Nein, nicht jetzt\"\n  },\n  \"searchbar\": {\n    \"placeholder\": \"Alle Dateien durchsuchen\",\n    \"empty\": \"Es wurde kein Ergebnis für die Suche \\\"%{query}\\\" gefunden\"\n  },\n  \"actions\": {\n    \"details\": \"Details\",\n    \"personalizeFolder\": {\n      \"label\": \"Ordner personalisieren\"\n    },\n    \"summariseByAI\": \"Zusammenfassen\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"Ordner personalisieren\",\n    \"description\": \"Wählen Sie eine bestimmte Farbe für Ihren Ordner\",\n    \"cancel\": \"Abbrechen\",\n    \"apply\": \"Anwenden\",\n    \"error\": \"Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut.\",\n    \"tabs\": {\n      \"colors\": \"Farben\",\n      \"icons\": \"Symbole\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"Zuletzt verwendet\",\n      \"chooseCustomIcon\": \"Wählen Sie ein benutzerdefiniertes Symbol\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/locales/en.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"My Drive\",\n    \"item_recent\": \"Recents\",\n    \"item_sharings\": \"Sharings\",\n    \"item_shared\": \"Shared by me\",\n    \"item_activity\": \"Activity\",\n    \"item_trash\": \"Bin\",\n    \"item_migration\": \"Migration\",\n    \"item_settings\": \"Settings\",\n    \"item_collect\": \"Administrative\",\n    \"item_shared_drives\": \"Shared drives\",\n    \"item_favorites\": \"Favorites\",\n    \"item_external_drives\": \"External drives\",\n    \"item_my_drive\": \"My Drive\",\n    \"btn-client\": \"Get Twake Drive for desktop\",\n    \"btn-client-web\": \"Get Twake\",\n    \"btn-client-mobile\": \"Take your personnal cloud with you: install %{name} on all your devices!\",\n    \"banner-txt-client\": \"Get %{name} for Desktop and synchronise your files safely to make them accessible at all times.\",\n    \"banner-btn-client\": \"Download\",\n    \"link-client\": \"https://cozy.io/en/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.flagship.mobile\",\n    \"link-client-ios\": \"https://apps.apple.com/app/cloud-personnel-cozy/id1600636174\",\n    \"link-client-web\": \"https://cozy.io/try-it\",\n    \"view_more\": \"View more\",\n    \"view_less\": \"View less\",\n    \"item_nextcloud\": \"Nextcloud\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"Files\",\n    \"title_recent\": \"Recent\",\n    \"title_sharings\": \"Sharings\",\n    \"title_shared\": \"Shared by me\",\n    \"title_activity\": \"Activity\",\n    \"title_trash\": \"Trash\",\n    \"label\": \"Show path\",\n    \"title_shared_drives\": \"Drives\",\n    \"title_favorites\": \"Favorites\"\n  },\n  \"Toolbar\": {\n    \"more\": \"More\"\n  },\n  \"toolbar\": {\n    \"menu_manage_access\": \"Manage access\",\n    \"menu_leave_shared_drive\": \"Leave shared folder\",\n    \"menu_upload\": \"Upload files\",\n    \"item_more\": \"More\",\n    \"menu_new_folder\": \"Folder\",\n    \"menu_new_shared_drive\": \"Shared drive\",\n    \"menu_select\": \"Select items\",\n    \"menu_share_folder\": \"Share folder\",\n    \"menu_download\": \"Download\",\n    \"menu_sync_cozy\": \"Synchronise to my Twake\",\n    \"add_to_mine\": \"Add to my Twake\",\n    \"menu_download_folder\": \"Download folder\",\n    \"menu_download_file\": \"Download this file\",\n    \"menu_create_note\": \"Note\",\n    \"menu_create_docs\": \"Docs\",\n    \"menu_create_shortcut\": \"Shortcut\",\n    \"share\": \"Share\",\n    \"trash\": \"Remove\",\n    \"delete_shared_drive\": \"Delete shared drive\",\n    \"leave\": \"Leave shared folder & delete it\",\n    \"menu_add\": \"Add\",\n    \"menu_create\": \"Create\",\n    \"menu_add_item\": \"Add an item\",\n    \"menu_onlyOffice\": {\n      \"text\": \"Text document\",\n      \"spreadsheet\": \"Spreadsheet\",\n      \"slide\": \"Presentation\"\n    },\n    \"select_all\": \"Select all\",\n    \"select_all_mobile\": \"all\",\n    \"clear_selection\": \"Clear Selection\",\n    \"clear_selection_mobile\": \"Clear\",\n    \"sharings_tab_all\": \"All\",\n    \"sharings_tab_drives\": \"Drives\"\n  },\n  \"Share\": {\n    \"create-cozy\": \"Create my Twake\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"cta\": \"Share\",\n      \"title\": \"Share\",\n      \"details\": {\n        \"title\": \"Sharing details\",\n        \"createdAt\": \"On %{date}\",\n        \"ro\": \"Can read\",\n        \"rw\": \"Can change\",\n        \"desc\": {\n          \"ro\": \"You can view, download, and add this content to your Twake. You will get updates by the owner, but you won't be able to update this content yourself.\",\n          \"rw\": \"You can view, update, delete and add this content to your Twake. Updates you make will be seen on other Cozies.\"\n        }\n      },\n      \"shared\": \"Shared\",\n      \"sharedByMe\": \"Shared by me\",\n      \"sharedWithMe\": \"Shared with me\",\n      \"sharedBy\": \"Shared by %{name}\",\n      \"shareByLink\": {\n        \"subtitle\": \"By public link\",\n        \"desc\": \"Anyone with the provided link can see and download your files.\",\n        \"creating\": \"Creating your link...\",\n        \"copy\": \"Copy link\",\n        \"copied\": \"Link has been copied to clipboard\",\n        \"failed\": \"Unable to copy to clipboard\"\n      },\n      \"shareByEmail\": {\n        \"subtitle\": \"By email\",\n        \"email\": \"To:\",\n        \"emailPlaceholder\": \"Enter the email address or name of the recipient\",\n        \"send\": \"Send\",\n        \"genericSuccess\": \"You sent an invite to %{count} contacts.\",\n        \"success\": \"You sent an invite to %{email}.\",\n        \"comingsoon\": \"Coming soon! You will be able to share documents and photos in a single click with your family, your friends, and even your coworkers. Don't worry, we'll let you know when it's ready!\",\n        \"onlyByLink\": \"This %{type} can only be shared by link, because\",\n        \"type\": {\n          \"file\": \"file\",\n          \"folder\": \"folder\"\n        },\n        \"hasSharedParent\": \"it has a shared parent\",\n        \"hasSharedChild\": \"it contains a shared element\"\n      },\n      \"revoke\": {\n        \"title\": \"Remove from sharing\",\n        \"desc\": \"This contact will keep a copy but the changes won't be synchrnoized anymore.\",\n        \"success\": \"You removed this shared file from %{email}.\"\n      },\n      \"revokeSelf\": {\n        \"title\": \"Remove me from sharing\",\n        \"desc\": \"You keep the content but it won't be updated between your Twake anymore.\",\n        \"success\": \"You were removed from this sharing.\"\n      },\n      \"sharingLink\": {\n        \"title\": \"Link to share\",\n        \"copy\": \"Copy\",\n        \"copied\": \"Copied\"\n      },\n      \"whoHasAccess\": {\n        \"title\": \"1 person has access |||| %{smart_count} people have access\"\n      },\n      \"protectedShare\": {\n        \"title\": \"Coming soon!\",\n        \"desc\": \"Share anything by email with your family and friends!\"\n      },\n      \"close\": \"Close\",\n      \"gettingLink\": \"Getting your link...\",\n      \"error\": {\n        \"generic\": \"An error occurred when creating the file share link, please try again.\",\n        \"revoke\": \"Woops, an error occurred. Please contact us so we can fix this issue as soon as possible.\"\n      },\n      \"specialCase\": {\n        \"base\": \"This %{type} cannot be shared but with a link as it\",\n        \"isInSharedFolder\": \"is in a shared folder\",\n        \"hasSharedFolder\": \"contains a shared folder\"\n      }\n    },\n    \"viewer-fallback\": \"If the file has started downloading, you can close this.\",\n    \"dropzone\": {\n      \"teaser\": \"Drop files to upload them to:\",\n      \"noFolderSupport\": \"Folder drag&drop is currently not supported by your browser. Please upload your files manually.\"\n    }\n  },\n  \"table\": {\n    \"head_name\": \"Name\",\n    \"head_update\": \"Last update\",\n    \"head_size\": \"Size\",\n    \"head_status\": \"Share\",\n    \"head_thumbnail_size\": \"Switch thumbnail size\",\n    \"head_view_mode\": \"View mode\",\n    \"head_view_list\": \"List view\",\n    \"head_view_grid\": \"Grid view\",\n    \"row_update_format\": \"LLL d, yyyy\",\n    \"row_update_format_full\": \"LLLL d, yyyy\",\n    \"row_read_only\": \"Share (Read only)\",\n    \"row_read_write\": \"Share (Read & Write)\",\n    \"row_size_symbols\": {\n      \"B\": \"B\",\n      \"KB\": \"KB\",\n      \"MB\": \"MB\",\n      \"GB\": \"GB\",\n      \"TB\": \"TB\",\n      \"PB\": \"PB\",\n      \"EB\": \"EB\",\n      \"ZB\": \"ZB\",\n      \"YB\": \"YB\"\n    },\n    \"row_sharing_shortcut_aria_label\": \"New sharing shortcut\",\n    \"load_more\": \"Load More\",\n    \"mobile\": {\n      \"head_name_asc\": \"A-Z\",\n      \"head_name_desc\": \"Z-A\",\n      \"head_updated_at_asc\": \"Oldest first\",\n      \"head_updated_at_desc\": \"Most recent first\",\n      \"head_size_asc\": \"Lightest first\",\n      \"head_size_desc\": \"Heavier first\"\n    },\n    \"tooltip\": {\n      \"carbonCopy\": {\n        \"title\": \"Carbon Copy\",\n        \"caption\": \"Indicates whether the document is defined as \\\"authentic and original\\\" by Twake Workplace, the host of your Twake, as it can claim that it comes directly from a third-party service, without having undergone any modification.\"\n      },\n      \"electronicSafe\": {\n        \"title\": \"Electronic Safe\",\n        \"caption\": \"Indicates whether the original document is secured by your personal digital safe with the certifications that give it probative value and a 50-year retention guarantee beyond its deposit.\"\n      }\n    }\n  },\n  \"Storage\": {\n    \"title\": \"Storage\",\n    \"availability\": \"%{smart_count} GB available\",\n    \"increase\": \"Increase the space\"\n  },\n  \"SelectionBar\": {\n    \"selected_count\": \"item selected |||| items selected\",\n    \"share\": \"Share\",\n    \"download\": \"Download\",\n    \"copy\": \"Copy\",\n    \"cut\": \"Cut\",\n    \"paste\": \"Paste\",\n    \"trash\": \"Remove\",\n    \"trash_all\": \"Remove all\",\n    \"destroy\": \"Delete permanently\",\n    \"rename\": \"Rename\",\n    \"restore\": \"Restore\",\n    \"close\": \"Close\",\n    \"openWith\": \"Open with...\",\n    \"applePreview\": \"Apple preview\",\n    \"forward\": \"Forward\",\n    \"forwardTo\": \"Forward to...\",\n    \"moveto\": \"Move to…\",\n    \"moveto_mobile\": \"Move\",\n    \"phone-download\": \"Make available offline\",\n    \"qualify\": \"Categorize\",\n    \"history\": \"History\",\n    \"more\": \"More\",\n    \"openWithinNextcloud\": \"Open within Nextcloud\"\n  },\n  \"DeleteConfirm\": {\n    \"title\": \"Delete %{filename}? |||| Delete %{smart_count} %{type}?\",\n    \"trash\": \"It will be moved to the Trash. |||| They will be moved to the Trash.\",\n    \"restore\": \"You can still restore it whenever you want. |||| You can still restore them whenever you want.\",\n    \"share_accepted\": \"Sharing will be stopped. The following contacts will keep a copy, but your changes will no longer be synchronised:\",\n    \"share_waiting\": \"Sharing will be stopped. The following contacts will no longer be able to accept sharing and will no longer be able to access shared content:\",\n    \"share_both\": \"Sharing will be stopped. This means that contacts who have stored files in their Twake will keep a copy, while other contacts will no longer be able to access shared content:\",\n    \"link\": \"Link sharing will no longer be active\",\n    \"referenced\": \"Some of the files within the selection are related to a photo album. They will be removed from it if you proceed to trash them.\",\n    \"cancel\": \"Cancel\",\n    \"delete\": \"Remove\"\n  },\n  \"EmptyTrashConfirm\": {\n    \"title\": \"Permanently delete?\",\n    \"forbidden\": \"You won't be able to access these files anymore.\",\n    \"restore\": \"You won't be able to restore these files if you didn't make a backup.\",\n    \"cancel\": \"Cancel\",\n    \"delete\": \"Delete all\",\n    \"processing\": \"Your trash is being emptied. This might take a few moments.\",\n    \"success\": \"The trash has been emptied.\",\n    \"error\": \"An error occurred, please try again.\"\n  },\n  \"DestroyConfirm\": {\n    \"title\": \"Delete %{filename}? |||| Delete %{smart_count} %{type}?\",\n    \"forbidden\": \"You won't be able to access this %{type} anymore. |||| You won't be able to access these %{type} anymore.\",\n    \"restore\": \"You won't be able to restore this %{type} if you didn't make a backup. |||| You won't be able to restore these %{type} if you didn't make a backup.\",\n    \"cancel\": \"Cancel\",\n    \"delete\": \"Delete permanently\",\n    \"success\": \"The %{type} has been deleted permanently. |||| %{smart_count} %{type} have been deleted permanently.\",\n    \"error\": \"An error occurred, please try again.\",\n    \"processing\": \"The deletion is in progress. This might take a few moments.\"\n  },\n  \"quotaalert\": {\n    \"title\": \"Your disk space is full :(\",\n    \"desc\": \"Please remove files, empty your trash or increase your disk space before uploading files again.\",\n    \"confirm\": \"OK\",\n    \"increase\": \"Increase your disk space\"\n  },\n  \"loading\": {\n    \"message\": \"Loading\",\n    \"onlyOfficeCreateInProgress\": \"Creating the current file...\"\n  },\n  \"empty\": {\n    \"title\": \"You don’t have any files in this folder.\",\n    \"text\": \"Select files on your computer or drag them here.\",\n    \"mobile_text\": \"Select files on your device.\",\n    \"trash_title\": \"You don’t have any deleted files.\",\n    \"trash_text\": \"Move files you don't need anymore to the Trash and permanently delete items to free up storage page.\",\n    \"shared-drive_text\": \"Create and share your first drive.\"\n  },\n  \"error\": {\n    \"open_folder\": \"Something went wrong when opening the folder.\",\n    \"open_file\": \"Something went wrong when opening the file.\",\n    \"button\": {\n      \"reload\": \"Refresh now\"\n    },\n    \"download_file\": {\n      \"offline\": \"You should be connected to download this file\",\n      \"missing\": \"This file is missing\"\n    },\n    \"paste_failed\": \"Failed to paste files. Please try again.\"\n  },\n  \"Error\": {\n    \"public_unshared_title\": \"Sorry, this link is no longer available.\",\n    \"public_unshared_text\": \"This link has expired, or it was removed by its owner. Let him or her know that you missed it!\",\n    \"generic\": \"Something went wrong. Wait a few minutes and retry.\"\n  },\n  \"alert\": {\n    \"could_not_open_file\": \"The file could not be opened\",\n    \"try_again\": \"An error has occurred, please try again in a moment.\",\n    \"restore_file_success\": \"The selection has been successfully restored.\",\n    \"trash_file_success\": \"The selection has been moved to the Trash.\",\n    \"trash_file_processing\": \"The move to Trash is in progress...\",\n    \"trash_shared_drive_success\": \"The shared drive has been moved to the Trash.\",\n    \"destroy_file_success\": \"The selection has been deleted permanently.\",\n    \"folder_name\": \"The element %{folderName} already exists, please choose a new name.\",\n    \"file_name\": \"The element %{fileName} already exists, please choose a new name.\",\n    \"file_name_missing\": \"The file name is missing, please choose a new name.\",\n    \"file_name_illegal_name\": \"The name %{fileName} is invalid, please choose a new name.\",\n    \"file_name_illegal_characters\": \"The element %{fileName} contains invalid characters: %{characters}\",\n    \"folder_generic\": \"An error occurred, please try again.\",\n    \"folder_abort\": \"You need to add a name to your new folder if you would like to save it. Your information has not been saved.\",\n    \"offline\": \"This feature is not available offline.\",\n    \"preparing\": \"Preparing your files…\",\n    \"item_copied\": \"1 item copied\",\n    \"items_copied\": \"%{count} items copied\",\n    \"item_cut\": \"1 item cut\",\n    \"items_cut\": \"%{count} items cut\",\n    \"item_moved\": \"1 item was moved\",\n    \"items_moved\": \"%{count} items were moved\",\n    \"item_pasted\": \"1 item was moved\",\n    \"items_pasted\": \"%{count} items were moved\",\n    \"copy_files_only\": \"Cannot copy folders\",\n    \"copy_not_allowed\": \"Copy operation is not allowed in this view.\",\n    \"cut_not_allowed\": \"Cut operation is not allowed in this view.\",\n    \"delete_not_allowed\": \"Delete operation is not allowed in this view.\",\n    \"paste_error\": \"An error occurred while pasting files\",\n    \"paste_failed\": \"Failed to paste files\",\n    \"paste_sharing_error\": \"Cannot paste files due to sharing restrictions. Please use the Move action instead.\",\n    \"paste_same_folder_skipped\": \"Cannot move items to the same folder they are already in.\",\n    \"paste_not_allowed\": \"You cannot paste into this folder\",\n    \"cannot_move_shared_drive\": \"You cannot move shared drive folder\",\n    \"cannot_copy_shared_drive\": \"You cannot copy shared drive folder\"\n  },\n  \"upload\": {\n    \"label\": \"Upload\",\n    \"documentType\": {\n      \"file\": \"file\",\n      \"directory\": \"folder\",\n      \"element\": \"element\"\n    },\n    \"alert\": {\n      \"success\": \"%{smart_count} %{type} uploaded with success. |||| %{smart_count} %{type} uploaded with success.\",\n      \"success_conflicts\": \"%{smart_count} %{type} uploaded with %{conflictNumber} conflict(s). |||| %{smart_count} %{type} uploaded with %{conflictNumber} conflict(s).\",\n      \"success_updated\": \"%{smart_count} %{type} uploaded and %{updatedCount} updated. |||| %{smart_count} %{type} uploaded and %{updatedCount} updated.\",\n      \"success_updated_conflicts\": \"%{smart_count} %{type} uploaded, %{updatedCount} updated and %{conflictCount} conflict(s). |||| %{smart_count} %{type} uploaded, %{updatedCount} updated and %{conflictCount} conflict(s).\",\n      \"updated\": \"%{smart_count} %{type} updated. |||| %{smart_count} %{type} updated.\",\n      \"updated_conflicts\": \"%{smart_count} %{type} updated with %{conflictCount} conflict(s). |||| %{smart_count} %{type} updated with %{conflictCount} conflict(s).\",\n      \"errors\": \"Errors occurred during the %{type} upload.\",\n      \"network\": \"You are currenly offline. Please try again once you're connected.\",\n      \"fileTooLargeErrors\": \"File too large. Maximum file size: %{max_size_value} GB\",\n      \"unreadable_files\": \"Some files could not be read. The file path may be too long or the folder was modified during the transfer.\"\n    },\n    \"limit\": {\n      \"title\": \"You cannot upload more than %{limit} files at a time.\",\n      \"content\": \"Need to upload more? Consider downloading the synchronization tool to your computer\",\n      \"content_public\": \"Please reduce the number of files and try again.\",\n      \"cancel\": \"Cancel\",\n      \"close\": \"Close\",\n      \"download_desktop\": \"Download on Desktop\"\n    }\n  },\n  \"intents\": {\n    \"alert\": {\n      \"error\": \"Unable to automatically upload the file, please upload it manually with the upload menu.\"\n    },\n    \"picker\": {\n      \"select\": \"Select\",\n      \"cancel\": \"Cancel\",\n      \"new_folder\": \"New folder\",\n      \"instructions\": \"Select a target\"\n    }\n  },\n  \"UploadQueue\": {\n    \"header\": \"Uploading %{smart_count} item to Twake Drive |||| Uploading %{smart_count} items to Twake Drive\",\n    \"header_preparing\": \"Preparing %{smart_count} item for upload |||| Preparing %{smart_count} items for upload\",\n    \"header_mobile\": \"Uploading %{done} of %{total}\",\n    \"header_done\": \"Uploaded %{done} out of %{total} successfully\",\n    \"success_flagship\": \"%{smart_count} file uploaded with success. |||| %{smart_count} files uploaded with success.\",\n    \"close\": \"close\",\n    \"item\": {\n      \"pending\": \"Pending\",\n      \"preparing\": \"Preparing\"\n    }\n  },\n  \"Viewer\": {\n    \"close\": \"Close\",\n    \"noviewer\": {\n      \"download\": \"Download this file\",\n      \"openWith\": \"Open with...\",\n      \"openInOnlyOffice\": \"Open with Only Office\",\n      \"cta\": {\n        \"saveTime\": \"Save some time!\",\n        \"installDesktop\": \"Install the synchronization tool for your computer\",\n        \"accessFiles\": \"Access your files directly on your computer\"\n      }\n    },\n    \"actions\": {\n      \"download\": \"Download\",\n      \"forward\": \"Forward\"\n    },\n    \"loading\": {\n      \"error\": \"This file could not be loaded. Do you have a working internet connection right now?\",\n      \"retry\": \"Retry\"\n    },\n    \"error\": {\n      \"noapp\": \"No application on your device can handle this file.\",\n      \"generic\": \"An error occurred when opening this file, please try again.\",\n      \"noNetwork\": \"You're currently offline.\"\n    },\n    \"panel\": {\n      \"title\": \"Useful information\"\n    }\n  },\n  \"Move\": {\n    \"to\": \"Move to:\",\n    \"action\": \"Move\",\n    \"cancel\": \"Cancel\",\n    \"modalTitle\": \"Move\",\n    \"title\": \"%{smart_count} element |||| %{smart_count} elements\",\n    \"success\": \"%{subject} has been moved to %{target}. |||| %{smart_count} elements have been moved to %{target}.\",\n    \"error\": \"Something went wrong while moving this element, please try again later. |||| Something went wrong while moving these elements, please try again later.\",\n    \"cancelled\": \"%{subject} has been moved back to it's original location. |||| %{smart_count} elements have been moved back to their original location.\",\n    \"cancelledWithRestoreErrors\": \"%{subject} has been moved back to it's original location but there was an error while restoring the file from trash. |||| %{smart_count} elements have been moved back to their original location but there was %{restoreErrorsCount} error(s) while restoring the file(s) from trash.\",\n    \"cancelled_error\": \"Sorry, there was an error while moving the element back. |||| Sorry, there was an error while moving these elements back.\",\n    \"multipleEntries\": \"%{smart_count} element |||| %{smart_count} elements\",\n    \"addFolder\": \"Add a folder\",\n    \"outsideSharedFolder\": {\n      \"title\": \"Moving outside the %{sharedFolder} folder\",\n      \"content_1\": \"Warning, you want to move %{name} out of the shared %{sharedFolder} folder. |||| Warning, you want to move %{smart_count} %{type} out of the shared %{sharedFolder} folder.\",\n      \"content_2\": \"This move, will remove the %{type} %{name} from the share. This %{type} will therefore be trashed for all members of the share. |||| This move, will remove %{smart_count} %{type} from the share. These %{type} will therefore be trashed for all members of the share.\",\n      \"cancel\": \"Cancel\",\n      \"confirm\": \"I understand\"\n    },\n    \"insideSharedFolder\": {\n      \"title\": \"Move to a shared folder?\",\n      \"content\": \"All members with access to %{destination} will also have access to %{source}. |||| All members with access to %{destination} will also have access to the selected %{type}.\",\n      \"cancel\": \"Cancel\",\n      \"confirm\": \"Ok\"\n    },\n    \"sharedFolderInsideAnother\": {\n      \"title\": \"Cannot be moved\",\n      \"content_1\": \"You want to move a shared element into a shared folder. This type of move is not allowed.\",\n      \"content_2\": \"If you still wish to move %{source} to %{destination}, please stop sharing :\",\n      \"cancel\": \"Cancel move\",\n      \"confirm\": \"Stop sharing\"\n    }\n  },\n  \"ImportToDrive\": {\n    \"title\": \"%{smart_count} element |||| %{smart_count} elements\",\n    \"to\": \"Save in:\",\n    \"action\": \"Save\",\n    \"cancel\": \"Cancel\",\n    \"success\": \"%{smart_count} saved file |||| %{smart_count} saved files\",\n    \"error\": \"Something went wrong. Please try again\"\n  },\n  \"FileOpenerExternal\": {\n    \"fileNotFoundError\": \"Error: file not found\"\n  },\n  \"TOS\": {\n    \"updated\": {\n      \"title\": \"GDPR comes into reality !\",\n      \"detail\": \"In the context of the General Data Protection Regulation, [our Terms of Service have been updated](%{link}) and will apply to all our Twake users on May 25, 2018.\",\n      \"cta\": \"Accept TOS and continue\",\n      \"disconnect\": \"Refuse and disconnect\",\n      \"error\": \"Something went wrong, please try again later\"\n    }\n  },\n  \"manifest\": {\n    \"permissions\": {\n      \"contacts\": {\n        \"description\": \"Required to share files with your contacts\"\n      },\n      \"groups\": {\n        \"description\": \"Required to share files with your groups\"\n      }\n    }\n  },\n  \"models\": {\n    \"contact\": {\n      \"defaultDisplayName\": \"Anonymous\"\n    }\n  },\n  \"Scan\": {\n    \"none\": \"Nothing\",\n    \"scan_a_doc\": \"Scan a doc\",\n    \"save_doc\": \"Save the doc\",\n    \"filename\": \"Filename\",\n    \"save\": \"Save\",\n    \"cancel\": \"Cancel\",\n    \"qualify\": \"Categorize\",\n    \"requalify\": \"Re-categorize\",\n    \"apply\": \"Apply\",\n    \"error\": {\n      \"offline\": \"You are currently offline and you can't use this functionnality. Try it later\",\n      \"uploading\": \"You are already uploading a file. Wait until the end of this upload and try again.\",\n      \"generic\": \"Something went wrong. Please try again.\"\n    },\n    \"successful\": {\n      \"qualified_ok\": \"You just have successfully categorized your file! \"\n    }\n  },\n  \"History\": {\n    \"description\": \"The last 20 versions of your files are automatically kept. Select a version to download it.\",\n    \"current_version\": \"Current version\",\n    \"loading\": \"Loading...\",\n    \"noFileVersionEnabled\": \"Your Twake will soon be able to archive the last modifications of a file to never risk losing them again\"\n  },\n  \"External\": {\n    \"redirection\": {\n      \"title\": \"Redirection\",\n      \"text\": \"You're about to be redirected…\",\n      \"error\": \"Error during the redirection. Generally, this means that the content of the file is not in the correct format.\"\n    }\n  },\n  \"RenameModal\": {\n    \"title\": \"Rename\",\n    \"description\": \"You're about to change the file's extension. Do you want to continue?\",\n    \"continue\": \"Continue\",\n    \"cancel\": \"Cancel\"\n  },\n  \"Shortcut\": {\n    \"title_modal\": \"Create a shortcut\",\n    \"filename\": \"Filename\",\n    \"url\": \"URL\",\n    \"cancel\": \"Cancel\",\n    \"create\": \"Create\",\n    \"created\": \"Your shortcut has been created\",\n    \"errored\": \"An error occured\",\n    \"filename_error_ends\": \"The name should end with .url\",\n    \"needs_info\": \"Shorcut needs at least an url and a filename\",\n    \"url_badformat\": \"Your url is not in the right format\"\n  },\n  \"OnlyOffice\": {\n    \"Error\": {\n      \"title\": \"Something goes wrong\",\n      \"text\": \"Please try to reload the page\"\n    },\n    \"readOnly\": {\n      \"title\": \"Read only\",\n      \"tooltip\": \"You are only authorized to view this document. Contact the owner to obtain writing privileges.\"\n    },\n    \"createFileName\": {\n      \"text\": \"New text document\",\n      \"spreadsheet\": \"New spreadsheet\",\n      \"slide\": \"New presentation\"\n    },\n    \"toolbar\": {\n      \"goToHome\": \"Go to home\"\n    },\n    \"actions\": {\n      \"edit\": \"Edit\",\n      \"validate\": \"Validate\"\n    },\n    \"tooltip\": {\n      \"title\": \"Edit document\",\n      \"text\": \"The document is currently read-only. You can modify it by clicking here.\",\n      \"actions\": {\n        \"ok\": \"Ok\",\n        \"hide\": \"Do not display\"\n      }\n    }\n  },\n  \"Migration\": {\n    \"title\": \"Update Twake Drive\",\n    \"content\": \"Twake Drive needs to update in order to improve its performances. This might take up to several minutes during which you cannot use your app. Do you want to do it now? If you refuse, we will ask you again next time\",\n    \"confirm\": \"Ok, let's do it!\",\n    \"cancel\": \"No, not now\"\n  },\n  \"searchbar\": {\n    \"placeholder\": \"Search anything\",\n    \"empty\": \"No result has been found for the query “%{query}”\"\n  },\n  \"button\": {\n    \"back\": \"Back\",\n    \"add\": \"Add\",\n    \"create\": \"Create\"\n  },\n  \"search\": {\n    \"action\": \"Search\",\n    \"empty\": {\n      \"title\": \"No result\",\n      \"subtitle\": \"No result has been found for the query “%{query}”\"\n    }\n  },\n  \"PushBanner\": {\n    \"quota\": {\n      \"text\": \"You've almost run out of storage space. If you reach the limit, you won't be able to add any more files. You can delete files, empty your bin or change your offer.\",\n      \"actions\": {\n        \"first\": \"I understand\",\n        \"second\": \"Check our plans\"\n      }\n    }\n  },\n  \"FileDivergedModal\": {\n    \"title\": \"Someone has modified this file\",\n    \"content\": \"Someone has modified the file outside Twake while you were editing it, you can retrieve their modifications instead of yours or continue your editing in a new file.\",\n    \"confirm\": \"Continue editing\",\n    \"cancel\": \"See its changes\",\n    \"error\": \"An error occurred, please try again.\",\n    \"confirmReload\": {\n      \"title\": \"See the changes\",\n      \"content\": \"When you access the new file, your changes will be cancelled.\",\n      \"cancel\": \"Cancel\",\n      \"confirm\": \"Ok, I get it\"\n    },\n    \"viewMode\": {\n      \"title\": \"Someone has modified this file\",\n      \"content\": \"Someone has changed the contents of this file. You can retrieve these changes.\",\n      \"confirm\": \"See the changes\"\n    }\n  },\n  \"FileDeletedModal\": {\n    \"title\": \"Someone has deleted this file\",\n    \"content\": \"Someone has deleted this file while you were editing it. You can stop editing or restore the file to continue editing.\",\n    \"confirm\": \"Restore file\",\n    \"cancel\": \"Undo changes\",\n    \"error\": \"An error occurred, please try again.\"\n  },\n  \"TrashedBanner\": {\n    \"text\": \"The item is in your trash\",\n    \"destroy\": \"Delete permanently\",\n    \"restore\": \"Restore\",\n    \"restoreSuccess\": \"The item has been restored\",\n    \"restoreError\": \"An error has occurred, please try again.\",\n    \"destroySuccess\": \"The item has been deleted\"\n  },\n  \"MigrationProgressBanner\": {\n    \"title\": \"Migration from Nextcloud in progress\",\n    \"percent\": \"%{percent}% complete\",\n    \"importing\": \"Importing %{count} files from Nextcloud...\",\n    \"cancel\": \"Cancel\",\n    \"done\": {\n      \"title\": \"Migration Complete!\",\n      \"body\": \"Successfully imported %{count} files from Nextcloud\"\n    }\n  },\n  \"EntriesType\": {\n    \"file\": \"file |||| files\",\n    \"directory\": \"folder |||| folders\",\n    \"element\": \"element |||| elements\"\n  },\n  \"NotFound\": {\n    \"title\": \"The element cannot be found\",\n    \"text\": \"We have not found anything at this address. This may be a typing error.\"\n  },\n  \"NextcloudBreadcrumb\": {\n    \"root\": \"Shared Drives\",\n    \"trash\": \"Trash\"\n  },\n  \"NextcloudToolbar\": {\n    \"share\": \"Share\"\n  },\n  \"NextcloudDeleteConfirm\": {\n    \"title\": \"Delete %{filename}? |||| Delete %{smart_count} %{type}?\",\n    \"trash\": \"This item will be moved to the Nextcloud trash. |||| These items will be moved to the Nextcloud trash.\",\n    \"restore\": \"You can always restore it whenever you want from Nextcloud.\",\n    \"error\": \"An error occurred, please try again.\",\n    \"cancel\": \"Cancel\",\n    \"delete\": \"Delete\"\n  },\n  \"FileName\": {\n    \"sharedDrive\": \"Drives\",\n    \"trash\": \"Trash\"\n  },\n  \"NextcloudBanner\": {\n    \"title\": \"The items below are displayed from a NextCloud drive and are not stored in your Twake.\"\n  },\n  \"favorites\": {\n    \"label\": {\n      \"add\": \"Add to favorites\",\n      \"addMobile\": \"Favorites\",\n      \"remove\": \"Remove from favorites\"\n    },\n    \"error\": \"An error occurred, please try again.\",\n    \"success\": {\n      \"add\": \"%{filename} has been added to favorites |||| These items have been added to favorites\",\n      \"remove\": \"%{filename} has been removed from favorites |||| These items have been removed from favorites\"\n    }\n  },\n  \"TrashToolbar\": {\n    \"emptyTrash\": \"Empty trash\"\n  },\n  \"RestoreNextcloudFile\": {\n    \"label\": \"Restore\",\n    \"success\": \"The item has been restored\",\n    \"error\": \"An error occurred, please try again.\"\n  },\n  \"actions\": {\n    \"details\": \"Details\",\n    \"infos\": \"Details and qualification\",\n    \"infosMobile\": \"Details\",\n    \"duplicateTo\": {\n      \"label\": \"Duplicate to…\"\n    },\n    \"duplicateToMobile\": {\n      \"label\": \"Duplicate\"\n    },\n    \"personalizeFolder\": {\n      \"label\": \"Personalize folder\"\n    },\n    \"summariseByAI\": \"Summarise\"\n  },\n  \"DuplicateModal\": {\n    \"subTitle\": \"Duplicate to:\",\n    \"confirmLabel\": \"Duplicate here\",\n    \"success\": \"%{fileName} has been duplicated to %{destinationName}. |||| %{smart_count} elements have been duplicated to %{destinationName}.\",\n    \"error\": \"An error occurred, please try again.\"\n  },\n  \"OpenFolderButton\": {\n    \"label\": \"Open directory\"\n  },\n  \"LastUpdate\": {\n    \"titleFormat\": \"LLLL dd, yyyy, HH:MM\"\n  },\n  \"AddMenu\": {\n    \"readOnlyFolder\": \"This is a read-only folder. You cannot perform this action.\"\n  },\n  \"PublicNoteRedirect\": {\n    \"error\": {\n      \"title\": \"Unable to access document\",\n      \"subtitle\": \"The share link appears to be missing or invalid. Please ask the document owner to check access\"\n    }\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"Personalize folder\",\n    \"description\": \"Choose a specific color for your folder\",\n    \"cancel\": \"Cancel\",\n    \"apply\": \"Apply\",\n    \"error\": \"An error occurred, please try again.\",\n    \"tabs\": {\n      \"colors\": \"Colors\",\n      \"icons\": \"Icons\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"Recents\",\n      \"chooseCustomIcon\": \"Choose a custom icon\"\n    }\n  },\n  \"antivirus\": {\n    \"infectedFile\": \"This file is infected with a virus\",\n    \"popover\": {\n      \"title\": \"Downloading and sharing is blocked for security reasons\",\n      \"description\": \"Twake system detected a virus\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/locales/es.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"Drive\",\n    \"item_recent\": \"Recientes\",\n    \"item_sharings\": \"Compartidos\",\n    \"item_shared\": \"Compartido por mí\",\n    \"item_activity\": \"Actividad\",\n    \"item_trash\": \"Papelera\",\n    \"item_settings\": \"Parámetros\",\n    \"item_collect\": \"Administración\",\n    \"btn-client\": \"Descargar Twake Drive para ordenador\",\n    \"btn-client-web\": \"Descargar Twake\",\n    \"btn-client-mobile\": \"Descargar %{name} en su celular\",\n    \"banner-txt-client\": \"Descargue %{name} para ordenador y sincronice sus archivos con toda seguridad para que les puedan ser accesibles todo el tiempo.\",\n    \"banner-btn-client\": \"Descargar\",\n    \"link-client\": \"https://cozy.io/es/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.drive.mobile\",\n    \"link-client-ios\": \"https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8\",\n    \"link-client-web\": \"https://cozy.io/try-it\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"Drive\",\n    \"title_recent\": \"Recientes\",\n    \"title_sharings\": \"Compartidos\",\n    \"title_shared\": \"Mis archivos compartidos\",\n    \"title_activity\": \"Actividad\",\n    \"title_trash\": \"Papelera\"\n  },\n  \"Toolbar\": {\n    \"more\": \"Más\"\n  },\n  \"toolbar\": {\n    \"menu_upload\": \"Cargar archivos\",\n    \"item_more\": \"Más\",\n    \"menu_new_folder\": \"Carpeta\",\n    \"menu_select\": \"Seleccionar los items\",\n    \"menu_share_folder\": \"Compartir carpeta\",\n    \"menu_download\": \"Descargar\",\n    \"menu_sync_cozy\": \"Sincronizar con mi Twake\",\n    \"add_to_mine\": \"Añadir a mi Twake\",\n    \"menu_download_folder\": \"Descargar carpeta\",\n    \"menu_download_file\": \"Descargar este archivo\",\n    \"menu_create_note\": \"Nota\",\n    \"empty_trash\": \"Vaciar la papelera\",\n    \"share\": \"Compartir\",\n    \"trash\": \"Suprimir\",\n    \"delete_shared_drive\": \"Eliminar unidad compartida\",\n    \"leave\": \"Salir de la carpeta compartida & borrarla\",\n    \"select_all\": \"Seleccionar todo\",\n    \"select_all_mobile\": \"todos\",\n    \"clear_selection\": \"Borrar selección\",\n    \"clear_selection_mobile\": \"Cancelar\",\n    \"sharings_tab_all\": \"Todo\",\n    \"sharings_tab_drives\": \"Unidades\"\n  },\n  \"Share\": {\n    \"create-cozy\": \"Crear mi Twake\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"cta\": \"Compartir\",\n      \"title\": \"Compartir\",\n      \"details\": {\n        \"title\": \"Detalles de lo compartido\",\n        \"createdAt\": \"El  %{date}\",\n        \"ro\": \"Puede leerlo\",\n        \"rw\": \"Puede cambiar\",\n        \"desc\": {\n          \"ro\": \"Usted puede consultar, descargar y añadir el contenido a su Coz. Recibirá las modificaciones que el propietario haga, pero usted no podrá modificarlo. \",\n          \"rw\": \"Usted puede consultar, modificar y suprimir el contenido. Las modificaciones del contenido se repercutirán automaticamente entre sus Twake.\"\n        }\n      },\n      \"sharedByMe\": \"Compartido por mí\",\n      \"sharedWithMe\": \"Compartido conmigo\",\n      \"sharedBy\": \"Compartido por %{name}\",\n      \"shareByLink\": {\n        \"subtitle\": \"Por enlace público\",\n        \"desc\": \"Quien disponga del enlace suministrado puede mirar y descargar sus archivos.\",\n        \"creating\": \"Creando el enlace...\",\n        \"copy\": \"Copiar el enlace\",\n        \"copied\": \"El enlace ha sido copiado en el portapapeles\",\n        \"failed\": \"No se puede copiar en el portapapeles\"\n      },\n      \"shareByEmail\": {\n        \"subtitle\": \"Por correo electrónico\",\n        \"email\": \"Para:\",\n        \"emailPlaceholder\": \"Entre la dirección email o el nombre del destinatario\",\n        \"send\": \"Enviar\",\n        \"genericSuccess\": \"Usted envía una invitación a %{count} contactos\",\n        \"success\": \"Ustad envía una invitación a %{email}.\",\n        \"comingsoon\": \"Dentro de poco, podrá compartir documentos y fotos en un solo clic con su familia, sus amigos e incluso sus compañeros de trabajo. No se preocupe, ¡le avisaremos cuando esté listo!\",\n        \"onlyByLink\": \"Este %{type} no se puede compartir con un enlace, ya que\",\n        \"type\": {\n          \"file\": \"Archivo\",\n          \"folder\": \"carpeta\"\n        },\n        \"hasSharedParent\": \"se encuentra en una carpeta compartida\",\n        \"hasSharedChild\": \"contiene un elemento compartido\"\n      },\n      \"revoke\": {\n        \"title\": \"Parar el intercambio\",\n        \"desc\": \"Su contacto conservará una copia pero los cambios que haga no se sincronizarán.\",\n        \"success\": \"Usted ha borrado este archivo compartido desde %{email}\"\n      },\n      \"revokeSelf\": {\n        \"title\": \"Parar el intercambio\",\n        \"desc\": \"Usted conservará el contenido pero no se actualizará más entre sus Twake.\",\n        \"success\": \"Usted fue borrado desde este compartir\"\n      },\n      \"sharingLink\": {\n        \"title\": \"Enlace a compartir\",\n        \"copy\": \"Copiar\",\n        \"copied\": \"Copiado\"\n      },\n      \"whoHasAccess\": {\n        \"title\": \"1 persona accede |||| %{smart_count} personas acceden\"\n      },\n      \"protectedShare\": {\n        \"title\": \"Vendrá pronto!\",\n        \"desc\": \"Compartir algo por email con su familia y sus amigos!\"\n      },\n      \"close\": \"Cerrar\",\n      \"gettingLink\": \"Creación del enlace...\",\n      \"error\": {\n        \"generic\": \"Ha ocurrido un error al usted crear el link para compartir el archivo, por favor vuelva a ensayar.\",\n        \"revoke\": \"Epa, Ha ocurrido un error. Contactos para resolver el problema cuanto antes.\"\n      },\n      \"specialCase\": {\n        \"base\": \"ste %{type} no se puede compartir sino con un enlace como éste\",\n        \"isInSharedFolder\": \"está en una carpeta compartida\",\n        \"hasSharedFolder\": \"contiene una carpeta compartida\"\n      }\n    },\n    \"viewer-fallback\": \"Si el archivo ha comenzado a descargarse, puede cerrar esta ventana..\",\n    \"dropzone\": {\n      \"teaser\": \"Ponga los archivos para subirlos en:\",\n      \"noFolderSupport\": \"Por el momento, su navegador no acepta las funciones de arrastar-soltar de carpetas. Por favor, suba los archivos manualmente.\"\n    }\n  },\n  \"table\": {\n    \"head_name\": \"Nombre\",\n    \"head_update\": \"Ultima actualización\",\n    \"head_size\": \"Tamaño\",\n    \"head_status\": \"Compartir\",\n    \"head_thumbnail_size\": \"Cambiar el tamaño de las miniaturas\",\n    \"row_update_format\": \"LLL d, yyyy\",\n    \"row_update_format_full\": \"LLL d, yyyy\",\n    \"row_read_only\": \"Compartido (sólo en lectura)\",\n    \"row_read_write\": \"Compartido (Lectura & Escritura)\",\n    \"row_size_symbols\": {\n      \"B\": \"o\",\n      \"KB\": \"Ko\",\n      \"MB\": \"Mo\",\n      \"GB\": \"Go\",\n      \"TB\": \"To\",\n      \"PB\": \"Po\",\n      \"EB\": \"Eo\",\n      \"ZB\": \"Zo\",\n      \"YB\": \"Yo\"\n    },\n    \"load_more\": \"Cargar más archivos\",\n    \"mobile\": {\n      \"head_name_asc\": \"A-Z\",\n      \"head_name_desc\": \"Z-A\",\n      \"head_updated_at_asc\": \"El más viejo primero\",\n      \"head_updated_at_desc\": \"El más reciente primero\",\n      \"head_size_asc\": \"El más liviano primero\",\n      \"head_size_desc\": \"El más pesado primero\"\n    },\n    \"tooltip\": {\n      \"carbonCopy\": {\n        \"title\": \"Copia Carbón\",\n        \"caption\": \"Indica si Twake Workplace, su sitio de hospedaje,  define el documento como \\\"auténtico y original\\\", ya que puede afirmar que proviene directamente de un servicio de terceros, sin haber sufrido ninguna modificación.\"\n      },\n      \"electronicSafe\": {\n        \"title\": \"Seguridad electrónica\",\n        \"caption\": \"Indica si el documento original está protegido por su seguridad digital personal con las certificaciones que le dan valor probatorio y una garantía de retención de 50 años más allá de su depósito si el documento es definido como \\\"auténtico y original\\\" por Twake Workplace, donde se hospeda su Twake, ya que puede afirmar que proviene directamente de un servicio de terceros, sin haber sufrido ninguna modificación.\\n\\n\"\n      }\n    }\n  },\n  \"Storage\": {\n    \"title\": \"Almacenamiento\",\n    \"availability\": \"%{smart_count} GB disponibles\",\n    \"increase\": \"Aumenta tu espacio\"\n  },\n  \"SelectionBar\": {\n    \"selected_count\": \"item seleccionado |||| items seleccionados\",\n    \"share\": \"Compartir\",\n    \"download\": \"Descargar\",\n    \"trash\": \"Borrar\",\n    \"destroy\": \"Borrar definitivamente\",\n    \"rename\": \"Cambiar el nombre\",\n    \"restore\": \"Restaurar\",\n    \"close\": \"Cerrar\",\n    \"openWith\": \"Abir con...\",\n    \"applePreview\": \"Vista preliminar de Apple\",\n    \"forward\": \"Enviar\",\n    \"forwardTo\": \"Enviar a...\",\n    \"moveto\": \"Trasladar a...\",\n    \"moveto_mobile\": \"Trasladar\",\n    \"phone-download\": \"Hacerla disponible cuando esté desconectado\",\n    \"qualify\": \"Clasificar\",\n    \"history\": \"Historia\"\n  },\n  \"DeleteConfirm\": {\n    \"title\": \"¿Suprimir este elemento? |||| ¿Suprimir estos elementos?\",\n    \"trash\": \"Será desplazado a la Papelera. ||| Serán desplazados a la Papelera.\",\n    \"restore\": \"Usted puede restaurarlo cuando lo desee. ||| Usted puede restaurarlos cuando lo desee.\",\n    \"link\": \"El intercambio de enlaces ya no estará activo\",\n    \"referenced\": \"Algunos de los archivos incluidos en la selección se refieren a un álbum de fotos. Se borrarán si usted procede a enviarlos a la papelera.\",\n    \"cancel\": \"Anular\",\n    \"delete\": \"Suprimir\"\n  },\n  \"emptytrashconfirmation\": {\n    \"title\": \"¿Suprimir definitivamente?\",\n    \"forbidden\": \"Usted no podrá acceder más a estos archivos.\",\n    \"restore\": \"Usted no podrá recuperar estos archivos si no ha hecho una copia de seguridad.\",\n    \"cancel\": \"Anular\",\n    \"delete\": \"Suprimir definitivamente\"\n  },\n  \"DestroyConfirm\": {\n    \"title\": \"¿Suprimir definitivamente?\",\n    \"forbidden\": \"Usted no podrá acceder más a este archivo. ||| Usted no podrá acceder más a estos archivos.\",\n    \"restore\": \"Usted no podrá recuperar este archivo si no ha hecho una copia de seguridad. ||| Usted no podrá recuperar estos archivos si no ha hecho una copia de seguridad.\",\n    \"cancel\": \"Anular\",\n    \"delete\": \"Suprimir definitivamente\"\n  },\n  \"quotaalert\": {\n    \"title\": \"Su espacio disco está lleno :(\",\n    \"desc\": \"Por favor, suprima archivos, vacíe su basura o aumente su espacio en el disco antes de volver a subir archivos.\",\n    \"confirm\": \"OK\",\n    \"increase\": \"Aumente su espacio disco\"\n  },\n  \"loading\": {\n    \"message\": \"Cargando\"\n  },\n  \"empty\": {\n    \"title\": \"No hay archivos en esta carpeta.\",\n    \"text\": \"Selecciona archivos en tu computadora o arrástralos aquí.\",\n    \"mobile_text\": \"Selecciona archivos en tu dispositivo.\",\n    \"trash_title\": \"Usted no tiene ningún archivo borrado.\",\n    \"trash_text\": \"Los archivos que no necesita más échelos a la Papelera y suprímalos definitivamente para liberar espacio de almacenamiento.\"\n  },\n  \"error\": {\n    \"open_folder\": \"Algo ha fallado al abrir la carpeta.\",\n    \"button\": {\n      \"reload\": \"Actualizar ahora\"\n    },\n    \"download_file\": {\n      \"offline\": \"Usted debe estar conectado para descargar este archivo\",\n      \"missing\": \"Este archivo no existe\"\n    }\n  },\n  \"Error\": {\n    \"public_unshared_title\": \"Lo sentimos, este enlace ya no es válido.\",\n    \"public_unshared_text\": \"Este enlace ha caducado o ha sido eliminado por su propietario. Hágale saber a él o ella que lo ha perdido!\",\n    \"generic\": \"Algo ha fallado. Espere algunos minutos y vuelva a ensayar.\"\n  },\n  \"alert\": {\n    \"could_not_open_file\": \"El archivo no se puede abrir\",\n    \"try_again\": \"Ha ocurrido un error, por favor ensaye más tarde.\",\n    \"restore_file_success\": \"La selección ha sido restaurada con éxito.\",\n    \"trash_file_success\": \"La selección ha sido desplazada a la Papelera.\",\n    \"destroy_file_success\": \"Se ha suprimido definitivamente la selección.\",\n    \"empty_trash_progress\": \"Su papelera se está vaciando. Esto puede tomar poco tiempo.\",\n    \"empty_trash_success\": \"La papelera ha sido vaciada.\",\n    \"folder_name\": \"El elemento %{folderName} ya existe, por favor escoger otro nombre.\",\n    \"file_name\": \"El elemento %{fileName} ya existe, por favor escoger otro nombre.\",\n    \"folder_generic\": \"Ha ocurrido un error, por favor vuelva a ensayar.\",\n    \"folder_abort\": \"Se requiere poner un nombre a la nueva carpeta si desea guardarla. Su información no ha sido guardada.\",\n    \"offline\": \"Esta función no esta disponible cuando usted está desconectado.\",\n    \"preparing\": \"Preparando sus archivos...\",\n    \"item_copied\": \"1 elemento copiado\",\n    \"items_copied\": \"%{count} elementos copiados\",\n    \"item_cut\": \"1 elemento cortado\",\n    \"items_cut\": \"%{count} elementos cortados\",\n    \"item_moved\": \"1 elemento ha sido movido\",\n    \"items_moved\": \"%{count} elementos han sido movidos\",\n    \"item_pasted\": \"1 elemento ha sido movido\",\n    \"items_pasted\": \"%{count} elementos han sido movidos\",\n    \"copy_files_only\": \"No se pueden copiar carpetas\",\n    \"copy_not_allowed\": \"La operación de copia no está permitida en esta vista.\",\n    \"cut_not_allowed\": \"La operación de corte no está permitida en esta vista.\",\n    \"paste_error\": \"Ha ocurrido un error al pegar los archivos\",\n    \"paste_failed\": \"Error al pegar los archivos\",\n    \"paste_sharing_error\": \"No se pueden pegar los archivos debido a restricciones de compartición. Por favor use la acción Mover en su lugar.\",\n    \"paste_same_folder_skipped\": \"No se pueden mover elementos a la misma carpeta en la que ya se encuentran.\",\n    \"paste_not_allowed\": \"No puedes pegar en esta carpeta\",\n    \"cannot_move_shared_drive\": \"No puedes mover la carpeta de unidad compartida\",\n    \"cannot_copy_shared_drive\": \"No puedes copiar la carpeta de la unidad compartida\"\n  },\n  \"upload\": {\n    \"label\": \"Subir\",\n    \"alert\": {\n      \"network\": \"Usted no dispone de una conexión internet. Vuelva a ensayar cuando disponga de una.\"\n    }\n  },\n  \"intents\": {\n    \"alert\": {\n      \"error\": \"La recuperación del archivo ha fallado. Súbalo manualmente con ayuda del menú de Twake.\"\n    },\n    \"picker\": {\n      \"select\": \"Seleccionar\",\n      \"cancel\": \"Anular\",\n      \"new_folder\": \"Nueva carpeta\",\n      \"instructions\": \"Seleccionar un blanco\"\n    }\n  },\n  \"UploadQueue\": {\n    \"header\": \"Subiendo %{smart_count} foto a Twake Drive |||| Subiendo %{smart_count} fotos a Twake Drive\",\n    \"header_mobile\": \"Subiendo %{done} de %{total}\",\n    \"header_done\": \"Subidos %{done} de %{total} con éxito\",\n    \"close\": \"cerrar\",\n    \"item\": {\n      \"pending\": \"Pendiente\"\n    }\n  },\n  \"Viewer\": {\n    \"close\": \"Cerrar\",\n    \"noviewer\": {\n      \"download\": \"Descargar este archivo\",\n      \"openWith\": \"Abir con...\",\n      \"cta\": {\n        \"saveTime\": \"¡Gane tiempo!\",\n        \"installDesktop\": \"Instale la herramienta de sincronización para su ordenador\",\n        \"accessFiles\": \"Acceda a sus archivos directamente desde su ordenador\"\n      }\n    },\n    \"actions\": {\n      \"download\": \"Descargar\",\n      \"forward\": \"Reenviar\"\n    },\n    \"loading\": {\n      \"error\": \"Este archivo no se puede cargar. ¿Tienes alguna conexión a Internet funcionando ahora?\",\n      \"retry\": \"Reinténtelo\"\n    },\n    \"error\": {\n      \"generic\": \"Se ha producido un error al abrir este archivo, por favor inténtelo de nuevo.\",\n      \"noNetwork\": \"Actualmente usted está desconectado.\"\n    },\n    \"panel\": {\n      \"title\": \"Información útil\"\n    }\n  },\n  \"Move\": {\n    \"to\": \"Trasladar a:\",\n    \"action\": \"Trasladar\",\n    \"cancel\": \"Anular\",\n    \"modalTitle\": \"Trasladar\",\n    \"title\": \"%{smart_count} elemento |||| %{smart_count} elementos\",\n    \"success\": \"%{subject} ha sido desplazado a %{target}. |||| %{smart_count} elementos han sido desplazados a %{target}.\",\n    \"error\": \"Ha ocurrido un error al desplazar este elemento, por favor vuelva a ensayar. |||| Ha ocurrido un error al desplazar estos elementos, por favor vuelva a ensayar.\",\n    \"cancelled\": \"%{subject} ha sido devuelto a su carpeta de origen. |||| %{smart_count} elementos han sido devueltos a sus carpetas de origen.\",\n    \"cancelledWithRestoreErrors\": \"%{subject} ha sido desplazado a su ubicación original pero hubo un error al restaurar el archivo de la papelera. |||| %{smart_count} elementos han sido desplazados a su ubicación original pero hubo  %{restoreErrorsCount} error(es) al  restaurar los archivos de la papelera.\",\n    \"cancelled_error\": \"Lo sentimos, ha ocurrido un error al anular el desplazamiento. |||| Lo sentimos, un error ha ocurrido al anular los desplazamientos.\"\n  },\n  \"ImportToDrive\": {\n    \"title\": \"%{smart_count} elemento |||| %{smart_count} elementos\",\n    \"to\": \"Guardado en:\",\n    \"action\": \"Guardar\",\n    \"cancel\": \"Anular\",\n    \"success\": \"%{smart_count} archivo guardado |||| %{smart_count} archivos guardados\",\n    \"error\": \"Algo ha fallado, vuelva a ensayar\"\n  },\n  \"FileOpenerExternal\": {\n    \"fileNotFoundError\": \"Error: archivo no encontrado\"\n  },\n  \"TOS\": {\n    \"updated\": {\n      \"title\": \"Lo nuevo en el RGPD\",\n      \"detail\": \"En el marco de la Reglamento General de Protección de Datos (RGPD), [nuestras Condiciones Generales de Utilización se han actualizado](%{link})  y se aplicarán a partir del 25 de mayo de 2018.\",\n      \"cta\": \"Aceptar CGU y continuar\",\n      \"disconnect\": \"Rechazar y desconectarse\",\n      \"error\": \"Algo ha fallado, vuelva a ensayar más tarde\"\n    }\n  },\n  \"manifest\": {\n    \"permissions\": {\n      \"contacts\": {\n        \"description\": \"Necesario para compartir archivos con sus contactos\"\n      },\n      \"groups\": {\n        \"description\": \"Necesario para compartir archivos con sus grupos\"\n      }\n    }\n  },\n  \"models\": {\n    \"contact\": {\n      \"defaultDisplayName\": \"Anónimo\"\n    }\n  },\n  \"Scan\": {\n    \"scan_a_doc\": \"Escanear un doc\",\n    \"save_doc\": \"Guardar el doc\",\n    \"filename\": \"Nombre del archivo\",\n    \"save\": \"Guardar\",\n    \"cancel\": \"Anular\",\n    \"qualify\": \"Clasificar\",\n    \"apply\": \"Aplicar\",\n    \"error\": {\n      \"offline\": \"Usted está actualmente fuera de línea y no puede utilizar esta funcionalidad. Vuelva a ensayarlo más tarde\",\n      \"uploading\": \"Ya está cargando un archivo. Espere hasta el final de la carga e inténtelo de nuevo.\",\n      \"generic\": \"Algo ha fallado, vuelva a ensayar.\"\n    },\n    \"successful\": {\n      \"qualified_ok\": \"¡Usted ha clasificado exitosamente su archivo!\"\n    }\n  },\n  \"History\": {\n    \"description\": \"Las últimas 20 versiones de sus archivos se guardan automáticamente. Seleccione una versión para descargarla.\",\n    \"current_version\": \"Versión actual\",\n    \"loading\": \"Cargando...\",\n    \"noFileVersionEnabled\": \"Su Twake pronto podrá archivar las últimas modificaciones de un archivo para no arriesgarse a perderlas en el futuro.\"\n  },\n  \"External\": {\n    \"redirection\": {\n      \"title\": \"Redireccionar\",\n      \"text\": \"Está a punto de ser redireccionado...\",\n      \"error\": \"Error durante la redirección. Generalmente, esto significa que el contenido del archivo no está en el formato correcto.\"\n    }\n  },\n  \"RenameModal\": {\n    \"title\": \"Cambiar el nombre\",\n    \"description\": \"Está a punto de cambiar la extensión del archivo. ¿Quiere continuar?\",\n    \"continue\": \"Continuar\",\n    \"cancel\": \"Anular\"\n  },\n  \"Shortcut\": {\n    \"title_modal\": \"Crear un atajo\",\n    \"filename\": \"Nombre del archivo\",\n    \"url\": \"URL\",\n    \"cancel\": \"Anular\",\n    \"create\": \"Crear\",\n    \"created\": \"Su atajo ha sido creado\",\n    \"errored\": \"Ha ocurrido un error\",\n    \"filename_error_ends\": \"El nombre debe terminar con .url\",\n    \"needs_info\": \"El atajo necesita al menos una url y un nombre de archivo\",\n    \"url_badformat\": \"Su url no está en el formato correcto\"\n  },\n  \"searchbar\": {\n    \"placeholder\": \"Buscar\",\n    \"empty\": \"No se ha encontrado ningún resultado para su consulta “%{query}”\"\n  },\n  \"search\": {\n    \"empty\": {\n      \"subtitle\": \"No se ha encontrado ningún resultado para su consulta “%{query}”\"\n    }\n  },\n  \"actions\": {\n    \"details\": \"Detalles\",\n    \"personalizeFolder\": {\n      \"label\": \"Personalizar carpeta\"\n    },\n    \"summariseByAI\": \"Resumir\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"Personalizar carpeta\",\n    \"description\": \"Elija un color específico para su carpeta\",\n    \"cancel\": \"Cancelar\",\n    \"apply\": \"Aplicar\",\n    \"error\": \"Se ha producido un error, por favor inténtelo de nuevo.\",\n    \"tabs\": {\n      \"colors\": \"Colores\",\n      \"icons\": \"Iconos\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"Recientes\",\n      \"chooseCustomIcon\": \"Elegir un icono personalizado\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/locales/fr.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"Mon Drive\",\n    \"item_recent\": \"Récents\",\n    \"item_sharings\": \"Partages\",\n    \"item_shared\": \"Partagés\",\n    \"item_activity\": \"Activité\",\n    \"item_trash\": \"Corbeille\",\n    \"item_migration\": \"Migration\",\n    \"item_settings\": \"Paramètres\",\n    \"item_collect\": \"Administratif\",\n    \"item_shared_drives\": \"Drives partagés\",\n    \"item_favorites\": \"Favoris\",\n    \"item_external_drives\": \"Disques externes\",\n    \"item_my_drive\": \"Mon Drive\",\n    \"btn-client\": \"Télécharger Twake Drive \",\n    \"btn-client-web\": \"Obtenez un Twake\",\n    \"btn-client-mobile\": \"Emportez votre cloud personnel avec vous : installez notre app %{name} !\",\n    \"banner-txt-client\": \"Installez %{name} pour ordinateur et synchronisez vos fichiers pour les rendre accessibles à tout moment.\",\n    \"banner-btn-client\": \"Télécharger\",\n    \"link-client\": \"https://cozy.io/fr/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.flagship.mobile\",\n    \"link-client-ios\": \"https://apps.apple.com/app/cloud-personnel-cozy/id1600636174\",\n    \"link-client-web\": \"https://cozy.io/try-it\",\n    \"view_more\": \"Voir plus\",\n    \"view_less\": \"Voir moins\",\n    \"item_nextcloud\": \"Nextcloud\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"Fichiers\",\n    \"title_recent\": \"Récents\",\n    \"title_sharings\": \"Partages\",\n    \"title_shared\": \"Mes fichiers partagés\",\n    \"title_activity\": \"Activité\",\n    \"title_trash\": \"Corbeille\",\n    \"label\": \"Voir le chemin\",\n    \"title_shared_drives\": \"Drives\",\n    \"title_favorites\": \"Favoris\"\n  },\n  \"Toolbar\": {\n    \"more\": \"Plus\"\n  },\n  \"toolbar\": {\n    \"menu_manage_access\": \"Gérer les accès\",\n    \"menu_leave_shared_drive\": \"Quitter le partage\",\n    \"menu_upload\": \"Importer des fichiers\",\n    \"item_more\": \"Plus\",\n    \"menu_new_folder\": \"Dossier\",\n    \"menu_new_shared_drive\": \"Drive partagé\",\n    \"menu_select\": \"Sélectionner les éléments\",\n    \"menu_share_folder\": \"Partager le dossier\",\n    \"menu_download\": \"Télécharger\",\n    \"menu_sync_cozy\": \"Synchroniser dans mon Twake\",\n    \"add_to_mine\": \"Ajouter à mon Twake\",\n    \"menu_download_folder\": \"Télécharger le dossier\",\n    \"menu_download_file\": \"Télécharger ce fichier\",\n    \"menu_create_note\": \"Note\",\n    \"menu_create_docs\": \"Docs\",\n    \"menu_create_shortcut\": \"Raccourci\",\n    \"share\": \"Partager\",\n    \"trash\": \"Supprimer\",\n    \"delete_shared_drive\": \"Supprimer le drive partagé\",\n    \"leave\": \"Quitter le partage et supprimer le dossier\",\n    \"menu_add\": \"Ajouter\",\n    \"menu_create\": \"Créer\",\n    \"menu_add_item\": \"Ajouter un élément\",\n    \"menu_onlyOffice\": {\n      \"text\": \"Document texte\",\n      \"spreadsheet\": \"Feuille de calcul\",\n      \"slide\": \"Présentation\"\n    },\n    \"select_all\": \"Tout sélectionner\",\n    \"select_all_mobile\": \"Tout\",\n    \"clear_selection\": \"Effacer la sélection\",\n    \"clear_selection_mobile\": \"Annuler\",\n    \"sharings_tab_all\": \"Tout\",\n    \"sharings_tab_drives\": \"Drives\"\n  },\n  \"Share\": {\n    \"create-cozy\": \"Créer mon Twake\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"cta\": \"Partager\",\n      \"title\": \"Partager\",\n      \"details\": {\n        \"title\": \"Détails du partage\",\n        \"createdAt\": \"Depuis le %{date}\",\n        \"ro\": \"Peut consulter\",\n        \"rw\": \"Peut modifier\",\n        \"desc\": {\n          \"ro\": \"Vous pouvez consulter, télécharger, et ajouter ce contenu à votre Twake. Vous recevrez les modifications faites par le propriétaire, mais vous ne pourrez pas le modifier.\",\n          \"rw\": \"Vous pouvez consulter, modifier et supprimer du contenu. Les modifications sur le contenu seront répercutées automatiquement entre vos Twake.\"\n        }\n      },\n      \"shared\": \"Partagé\",\n      \"sharedByMe\": \"Partagé\",\n      \"sharedWithMe\": \"Partagé avec moi\",\n      \"sharedBy\": \"Partagé par %{name}\",\n      \"shareByLink\": {\n        \"subtitle\": \"Par lien public\",\n        \"desc\": \"Chaque personne possédant le lien fourni peut voir et télécharger vos fichiers.\",\n        \"creating\": \"Création du lien...\",\n        \"copy\": \"Copier le lien\",\n        \"copied\": \"Lien copié dans le presse-papiers.\",\n        \"failed\": \"Impossible de copier dans le presse papier\"\n      },\n      \"shareByEmail\": {\n        \"subtitle\": \"Par email\",\n        \"email\": \"À :\",\n        \"emailPlaceholder\": \"Saisissez le courriel ou le nom du destinataire.\",\n        \"send\": \"Envoyer\",\n        \"genericSuccess\": \"Vous avez invité %{count} contacts.\",\n        \"success\": \"Vous avez envoyé une invitation à %{email}.\",\n        \"comingsoon\": \"Bientôt disponible ! Vous pourrez partager un document et vos photos en un seul clic avec votre famille, vos amis, et même vos collaborateurs. Ne vous inquiétez pas, on vous prévient quand ce sera prêt !\",\n        \"onlyByLink\": \"Ce %{type} ne peut être partagé que sous la forme d'un lien, car il\",\n        \"type\": {\n          \"file\": \"fichier\",\n          \"folder\": \"dossier\"\n        },\n        \"hasSharedParent\": \"se trouve dans un dossier partagé.\",\n        \"hasSharedChild\": \"contient un élément partagé.\"\n      },\n      \"revoke\": {\n        \"title\": \"Arrêter le partage\",\n        \"desc\": \"Votre contact conservera une copie mais vos changements ne seront plus synchronisés.\",\n        \"success\": \"Vous avez cessé de partager ce fichier avec %{email}.\"\n      },\n      \"revokeSelf\": {\n        \"title\": \"Arrêter le partage\",\n        \"desc\": \"Vous conservez le contenu mais il ne sera plus mis à jour entre vos Twake.\",\n        \"success\": \"Vous avez été retiré de ce partage.\"\n      },\n      \"sharingLink\": {\n        \"title\": \"Partager\",\n        \"copy\": \"Copier\",\n        \"copied\": \"Copié\"\n      },\n      \"whoHasAccess\": {\n        \"title\": \"1 personne y a accès |||| %{smart_count} personnes y ont accès\"\n      },\n      \"protectedShare\": {\n        \"title\": \"Prochainement !\",\n        \"desc\": \"Partagez ce que vous souhaitez par email avec votre famille et vos amis !\"\n      },\n      \"close\": \"Fermer\",\n      \"gettingLink\": \"Création du lien…\",\n      \"error\": {\n        \"generic\": \"Une erreur est survenue lors de la création du lien de partage, merci de réessayer\",\n        \"revoke\": \"Oups, une erreur est survenue. Contactez-nous pour que nous résolvions la situation au plus vite.\\n\"\n      },\n      \"specialCase\": {\n        \"base\": \"Ce %{type} ne peut être partagé que sous la forme d'un lien, car il\",\n        \"isInSharedFolder\": \"se trouve dans un dossier partagé.\",\n        \"hasSharedFolder\": \"contient un dossier partagé.\"\n      }\n    },\n    \"viewer-fallback\": \"Le fichier est en cours de téléchargement, vous pouvez fermer cette fenêtre.\",\n    \"dropzone\": {\n      \"teaser\": \"Déposez des fichiers pour les importer vers :\",\n      \"noFolderSupport\": \"Votre navigateur ne prend pas en charge le glisser-déposer de dossier pour le moment. Veuillez importer les fichiers manuellement.\"\n    }\n  },\n  \"table\": {\n    \"head_name\": \"Nom\",\n    \"head_update\": \"Mise à jour\",\n    \"head_size\": \"Taille\",\n    \"head_status\": \"Partage\",\n    \"head_thumbnail_size\": \"Changer la taille des miniatures\",\n    \"head_view_mode\": \"Mode d'affichage\",\n    \"head_view_list\": \"Vue liste\",\n    \"head_view_grid\": \"Vue grille\",\n    \"row_update_format\": \"d LLL yyyy\",\n    \"row_update_format_full\": \"d LLLL yyyy\",\n    \"row_read_only\": \"Partagé (lecture seule)\",\n    \"row_read_write\": \"Partagé (lecture & écriture)\",\n    \"row_size_symbols\": {\n      \"B\": \"o\",\n      \"KB\": \"Ko\",\n      \"MB\": \"Mo\",\n      \"GB\": \"Go\",\n      \"TB\": \"To\",\n      \"PB\": \"Po\",\n      \"EB\": \"Eo\",\n      \"ZB\": \"Zo\",\n      \"YB\": \"Yo\"\n    },\n    \"row_sharing_shortcut_aria_label\": \"Nouveau raccourci de partage\",\n    \"load_more\": \"Plus de fichiers\",\n    \"mobile\": {\n      \"head_name_asc\": \"A-Z\",\n      \"head_name_desc\": \"Z-A\",\n      \"head_updated_at_asc\": \"Plus anciens en premier\",\n      \"head_updated_at_desc\": \"Plus récents en premier\",\n      \"head_size_asc\": \"Plus légers en premier\",\n      \"head_size_desc\": \"Plus lourds en premier\"\n    },\n    \"tooltip\": {\n      \"carbonCopy\": {\n        \"title\": \"Copie conforme\",\n        \"caption\": \"Le document est défini \\\"authentique et original\\\" par Twake Workplace, l'hébergeur de votre Twake, car il peut affirmer qu'il provient directement des services de son émetteur sans avoir subi aucune modification.\"\n      },\n      \"electronicSafe\": {\n        \"title\": \"Coffre-fort numérique\",\n        \"caption\": \"Indique si le document original est sécurisé par votre coffre-fort numérique personnel avec les certifications qui lui confèrent une valeur probante et une garantie de conservation de 50 ans au-delà de son dépôt.\"\n      }\n    }\n  },\n  \"Storage\": {\n    \"title\": \"Stockage\",\n    \"availability\": \"%{smart_count} Go disponible\",\n    \"increase\": \"Augmenter l'espace\"\n  },\n  \"SelectionBar\": {\n    \"selected_count\": \"élément sélectionné |||| éléments sélectionnés\",\n    \"share\": \"Partager\",\n    \"download\": \"Télécharger\",\n    \"trash\": \"Supprimer\",\n    \"trash_all\": \"Supprimer tout\",\n    \"destroy\": \"Supprimer définitivement\",\n    \"rename\": \"Renommer\",\n    \"restore\": \"Restaurer\",\n    \"close\": \"Fermer\",\n    \"openWith\": \"Ouvrir avec...\",\n    \"applePreview\": \"Aperçu Apple\",\n    \"forward\": \"Transférer\",\n    \"forwardTo\": \"Transférer vers...\",\n    \"moveto\": \"Déplacer vers…\",\n    \"moveto_mobile\": \"Déplacer\",\n    \"phone-download\": \"Rendre accessible hors-ligne\",\n    \"requalify\": \"Requalifier\",\n    \"qualify\": \"Qualifier\",\n    \"history\": \"Versions\",\n    \"more\": \"Afficher plus d'action\",\n    \"openWithinNextcloud\": \"Ouvrir dans Nextcloud\"\n  },\n  \"DeleteConfirm\": {\n    \"title\": \"Supprimer %{filename} ? |||| Supprimer %{smart_count} %{type} ?\",\n    \"trash\": \"Cet élément sera déplacé dans la corbeille. |||| Ces éléments seront déplacés dans la corbeille.\",\n    \"restore\": \"Vous pouvez toujours le restaurer quand vous voulez.\",\n    \"share_accepted\": \"Le partage sera arrêté. Ainsi, les contacts suivant conserveront une copie mais vos changements ne seront plus synchronisés :\",\n    \"share_waiting\": \"Le partage sera arrêté. Ainsi, les contacts suivant ne pourront donc plus accepter le partage et ne pourront plus accéder aux contenus partagés :\",\n    \"share_both\": \"Le partage sera arrêté. Ainsi, les contacts ayant stocké les fichiers dans leur Twake conserveront une copie, les autres contacts ne pourront plus accéder aux contenus partagés :\",\n    \"link\": \"Le partage par lien ne sera plus actif.\",\n    \"referenced\": \"Des photos de la sélection sont dans un album. Elles seront retirées de l'album si vous confirmez.\",\n    \"cancel\": \"Annuler\",\n    \"delete\": \"Supprimer\"\n  },\n  \"EmptyTrashConfirm\": {\n    \"title\": \"Supprimer définitivement ?\",\n    \"forbidden\": \"Vous ne pourrez plus accéder à ces fichiers.\",\n    \"restore\": \"Vous ne pourrez pas restaurer ces fichiers.\",\n    \"cancel\": \"Annuler\",\n    \"delete\": \"Tout supprimer\",\n    \"processing\": \"Votre corbeille est en train de se vider. Cela peut prendre quelques instants.\",\n    \"success\": \"La corbeille a été vidée.\",\n    \"error\": \"Une erreur est survenue, merci de réessayer.\"\n  },\n  \"DestroyConfirm\": {\n    \"title\": \"Supprimer %{filename} ? |||| Supprimer %{smart_count} %{type} ?\",\n    \"forbidden\": \"Vous ne pourrez plus accéder à ce %{type}. |||| Vous ne pourrez plus accéder à ces %{type}.\",\n    \"restore\": \"Vous ne pourrez pas restaurer ce %{type}. |||| Vous ne pourrez pas restaurer ces %{type}.\",\n    \"cancel\": \"Annuler\",\n    \"delete\": \"Supprimer définitivement\",\n    \"success\": \"Le %{type} a été supprimé définitivement. |||| %{smart_count} %{type} ont été supprimés définitivement.\",\n    \"error\": \"Une erreur est survenue, merci de réessayer.\",\n    \"processing\": \"La suppression est en cours. Cela peut prendre quelques instants.\"\n  },\n  \"quotaalert\": {\n    \"title\": \"Votre espace disque est plein :(\",\n    \"desc\": \"Veuillez supprimer des fichiers, vider votre corbeille ou augmenter votre espace disque avant d'importer de nouveau fichier.\",\n    \"confirm\": \"OK\",\n    \"increase\": \"Augmenter votre espace disque\"\n  },\n  \"loading\": {\n    \"message\": \"Chargement\",\n    \"onlyOfficeCreateInProgress\": \"Création du fichier en cours...\"\n  },\n  \"empty\": {\n    \"title\": \"Vous n'avez aucun fichier dans ce dossier.\",\n    \"text\": \"Sélectionnez les fichiers sur votre ordinateur ou faites-les glisser ici.\",\n    \"mobile_text\": \"Sélectionnez les fichiers sur votre appareil.\",\n    \"trash_title\": \"Vous n'avez aucun fichier supprimé.\",\n    \"trash_text\": \"Déplacez les fichiers dont vous n'avez plus besoin dans la corbeille et supprimez-les définitivement pour récupérer de l'espace de stockage.\",\n    \"shared-drive_text\": \"Créez et partagez votre premier drive.\"\n  },\n  \"error\": {\n    \"open_folder\": \"Une erreur est survenue pendant l'ouverture du dossier.\",\n    \"open_file\": \"Une erreur est survenue pendant l'ouverture du fichier.\",\n    \"button\": {\n      \"reload\": \"Rafraîchir\"\n    },\n    \"download_file\": {\n      \"offline\": \"Vous devez être connecté pour pouvoir ouvrir ce fichier\",\n      \"missing\": \"Le fichier n'existe pas\"\n    }\n  },\n  \"Error\": {\n    \"public_unshared_title\": \"Désolé, ce lien n'est plus disponible.\",\n    \"public_unshared_text\": \"Ce lien a expiré ou il a été supprimé par le ou la propriétaire. Signalez-lui que vous voulez accéder à son contenu !\",\n    \"generic\": \"Une erreur s'est produite. Attendez quelques minutes et recommencez.\"\n  },\n  \"alert\": {\n    \"could_not_open_file\": \"Impossible d'ouvrir le fichier\",\n    \"try_again\": \"Une erreur est survenue, merci de réessayer dans un instant.\",\n    \"restore_file_success\": \"La sélection a été restaurée avec succès.\",\n    \"trash_file_success\": \"La sélection a été déplacée dans la Corbeille.\",\n    \"trash_file_processing\": \"Le déplacement vers la Corbeille est en cours...\",\n    \"trash_shared_drive_success\": \"Le drive partagé a été déplacé dans la Corbeille.\",\n    \"destroy_file_success\": \"La sélection a été supprimée définitivement.\",\n    \"folder_name\": \"L'élément %{folderName} existe déjà, merci de choisir un nouveau nom.\",\n    \"file_name\": \"L'élément %{fileName} existe déjà, utilisez un nouveau nom\",\n    \"file_name_missing\": \"Le nom du fichier est manquant, veuillez choisir un nouveau nom.\",\n    \"file_name_illegal_name\": \"Le nom du fichier %{fileName} est invalide, veuillez choisir un nouveau nom.\",\n    \"file_name_illegal_characters\": \"Le nom du fichier %{fileName} est invalide, il contient les caractères interdits suivants : %{characters}\",\n    \"folder_generic\": \"Une erreur est survenue, merci de réessayer.\",\n    \"folder_abort\": \"Vous devez nommer votre dossier si vous voulez le sauvegarder. Vos informations n'ont pas été enregistrées.\",\n    \"offline\": \"Cette fonctionnalité n’est pas disponible en mode hors-ligne.\",\n    \"preparing\": \"Préparation de vos fichiers...\",\n    \"item_copied\": \"1 élément copié\",\n    \"items_copied\": \"%{count} éléments copiés\",\n    \"item_cut\": \"1 élément coupé\",\n    \"items_cut\": \"%{count} éléments coupés\",\n    \"item_moved\": \"1 élément a été déplacé\",\n    \"items_moved\": \"%{count} éléments ont été déplacés\",\n    \"item_pasted\": \"1 élément a été déplacé\",\n    \"items_pasted\": \"%{count} éléments ont été déplacés\",\n    \"copy_files_only\": \"Impossible de copier les dossiers\",\n    \"copy_not_allowed\": \"L'opération de copie n'est pas autorisée dans cette vue.\",\n    \"cut_not_allowed\": \"L'opération de coupe n'est pas autorisée dans cette vue.\",\n    \"delete_not_allowed\": \"L'opération de suppression n'est pas autorisée dans cette vue.\",\n    \"paste_error\": \"Une erreur s'est produite lors du collage des fichiers\",\n    \"paste_failed\": \"Échec du collage des fichiers\",\n    \"paste_sharing_error\": \"Impossible de coller les fichiers en raison de restrictions de partage. Veuillez utiliser l'action Déplacer à la place.\",\n    \"paste_same_folder_skipped\": \"Impossible de déplacer les éléments dans le même dossier où ils se trouvent déjà.\",\n    \"paste_not_allowed\": \"Vous ne pouvez pas coller dans ce dossier\",\n    \"cannot_move_shared_drive\": \"Vous ne pouvez pas déplacer le dossier de lecteur partagé\",\n    \"cannot_copy_shared_drive\": \"Vous ne pouvez pas copier le dossier du lecteur partagé\"\n  },\n  \"upload\": {\n    \"label\": \"Importer\",\n    \"documentType\": {\n      \"file\": \"fichier\",\n      \"directory\": \"dossier\",\n      \"element\": \"élément\"\n    },\n    \"alert\": {\n      \"success\": \"%{smart_count} %{type} importé. |||| %{smart_count} %{type} importés.\",\n      \"success_conflicts\": \"%{smart_count} %{type} importé avec %{conflictNumber} conflit(s). |||| %{smart_count} %{type} importés avec %{conflictNumber} conflit(s).\",\n      \"success_updated\": \"%{smart_count} %{type} importé et %{updatedCount} mis à jour. |||| %{smart_count} %{type} importés et %{updatedCount} mis à jour.\",\n      \"success_updated_conflicts\": \"%{smart_count} %{type} importé, %{updatedCount} mis à jour et %{conflictCount} conflit(s). |||| %{smart_count} %{type} importés, %{updatedCount} mis à jour et %{conflictCount} conflit(s).\",\n      \"updated\": \"%{smart_count} %{type} mis à jour. |||| %{smart_count} %{type} mis à jour.\",\n      \"updated_conflicts\": \"%{smart_count} %{type} mis à jour avec %{conflictCount} conflit(s). |||| %{smart_count} %{type} mis à jour avec %{conflictCount} conflit(s).\",\n      \"errors\": \"Une erreur est survenue lors de l’import du %{type}, merci de réessayer plus tard.\",\n      \"network\": \"Vous ne disposez pas d'une connexion internet. Merci de réessayer quand ce sera le cas.\",\n      \"fileTooLargeErrors\": \"Fichier trop volumineux. Taille maximale autorisée par fichier : %{max_size_value} Go\",\n      \"unreadable_files\": \"Certains fichiers n'ont pas pu être lus. Le chemin est peut-être trop long ou le dossier a été modifié pendant le transfert.\"\n    },\n    \"limit\": {\n      \"title\": \"Importation impossible : Ce dossier contient plus de %{limit} fichiers\",\n      \"content\": \"Pour une importation de cette taille, nous vous recommandons d'utiliser l'application de synchronisation sur ordinateur.\",\n      \"content_public\": \"Veuillez réduire le nombre de fichiers et réessayer.\",\n      \"cancel\": \"Annuler\",\n      \"close\": \"Fermer\",\n      \"download_desktop\": \"Installer l'application\"\n    }\n  },\n  \"intents\": {\n    \"alert\": {\n      \"error\": \"La récupération du fichier a échoué. Téléchargez le fichier manuellement puis ajoutez-le à Twake. \"\n    },\n    \"picker\": {\n      \"select\": \"Sélectionner\",\n      \"cancel\": \"Annuler\",\n      \"new_folder\": \"Nouveau dossier\",\n      \"instructions\": \"Choisir une cible\"\n    }\n  },\n  \"UploadQueue\": {\n    \"header\": \"Import de %{smart_count} élément dans votre Twake |||| Import de %{smart_count} éléments dans votre Twake\",\n    \"header_preparing\": \"Préparation de %{smart_count} élément |||| Préparation de %{smart_count} éléments\",\n    \"header_mobile\": \"Import de %{done} sur %{total}\",\n    \"header_done\": \"%{done} sur %{total} élément(s) importé(s)\",\n    \"success_flagship\": \"%{smart_count} fichier importé avec succès. |||| %{smart_count} fichiers importés avec succès.\",\n    \"close\": \"Fermer\",\n    \"item\": {\n      \"pending\": \"En attente\",\n      \"preparing\": \"En préparation\"\n    }\n  },\n  \"Viewer\": {\n    \"close\": \"Fermer\",\n    \"noviewer\": {\n      \"download\": \"Télécharger ce fichier\",\n      \"openWith\": \"Ouvrir avec...\",\n      \"openInOnlyOffice\": \"Ouvrir avec Only Office\",\n      \"cta\": {\n        \"saveTime\": \"Gagnez du temps !\",\n        \"installDesktop\": \"Installez l'outil de synchronisation pour ordinateur\",\n        \"accessFiles\": \"Accédez à vos fichiers directement sur votre ordinateur\"\n      }\n    },\n    \"actions\": {\n      \"download\": \"Télécharger\",\n      \"forward\": \"Transférer\"\n    },\n    \"loading\": {\n      \"error\": \"Ce fichier n'a pas pu être chargé. Avez-vous une connexion internet qui fonctionne actuellement ?\",\n      \"retry\": \"Réessayer\"\n    },\n    \"error\": {\n      \"noapp\": \"Votre téléphone n'a identifié aucune application pour lire ce type de fichier.\",\n      \"generic\": \"Une erreur est survenue lors de l'ouverture de ce fichier, merci de réessayer.\",\n      \"noNetwork\": \"Vous êtes actuellement hors ligne.\"\n    },\n    \"panel\": {\n      \"title\": \"Informations utiles\"\n    }\n  },\n  \"Move\": {\n    \"to\": \"Déplacer vers :\",\n    \"action\": \"Déplacer\",\n    \"cancel\": \"Annuler\",\n    \"modalTitle\": \"Déplacer\",\n    \"title\": \"%{smart_count} élément |||| %{smart_count} éléments\",\n    \"success\": \"%{subject} a été déplacé dans %{target}. |||| %{smart_count} éléments ont été déplacés dans %{target}.\",\n    \"error\": \"Une erreur est survenue pendant le déplacement de cet élément, merci de réessayer plus tard. |||| Une erreur est survenue pendant le déplacement de ces éléments, merci de réessayer plus tard.\",\n    \"cancelled\": \"%{subject} a été rapatrié dans son dossier d’origine. |||| %{smart_count} éléments ont été rapatriés dans leur dossiers d’origine.\",\n    \"cancelledWithRestoreErrors\": \"%{subject} a été rapatrié dans son dossier d'origine mais il y a eu une erreur lors de la restauration du fichier depuis la corbeille. |||| %{smart_count} éléments ont été rapatriés dans leur dossiers d'origine mais il y a eu %{restoreErrorsCount} erreur(s) lors de la restauration des fichiers depuis la corbeille.\",\n    \"cancelled_error\": \"Une erreur est survenue lors de l’annulation du déplacement. |||| Une erreur est survenue lors de l’annulation de ces déplacements.\",\n    \"multipleEntries\": \"%{smart_count} élément |||| %{smart_count} éléments\",\n    \"addFolder\": \"Ajouter un dossier\",\n    \"outsideSharedFolder\": {\n      \"title\": \"Déplacement en dehors du dossier %{sharedFolder}\",\n      \"content_1\": \"Attention, vous souhaitez déplacer %{name} en dehors du dossier partagé %{sharedFolder}. |||| Attention, vous souhaitez déplacer %{smart_count} %{type} en dehors du dossier partagé %{sharedFolder}.\",\n      \"content_2\": \"Ce déplacement, va retirer le %{type} %{name} du partage. Ce %{type} va donc être mis à la corbeille pour l'ensemble des membres du partage. |||| Ce déplacement, va retirer les %{smart_count} %{type} du partage. Ces %{type} vont donc être mis à la corbeille pour l'ensemble des membres du partage.\",\n      \"cancel\": \"Annuler\",\n      \"confirm\": \"J'ai compris\"\n    },\n    \"insideSharedFolder\": {\n      \"title\": \"Déplacer vers un dossier partagé ?\",\n      \"content\": \"Tous les membres ayant accès à %{destination} auront également accès à %{source}. |||| Tous les membres ayant accès à %{destination} auront également accès aux %{type} sélectionnés.\",\n      \"cancel\": \"Annuler\",\n      \"confirm\": \"Ok\"\n    },\n    \"sharedFolderInsideAnother\": {\n      \"title\": \"Déplacement impossible\",\n      \"content_1\": \"Vous souhaitez déplacer un élément partagé dans un dossier lui-même partagé. Ce type déplacement n'est pas autorisé.\",\n      \"content_2\": \"Si vous souhaitez tout de même déplacer %{source} dans %{destination}, veuillez arrêter le partage de :\",\n      \"cancel\": \"Annuler le déplacement\",\n      \"confirm\": \"Arrêter le partage\"\n    }\n  },\n  \"ImportToDrive\": {\n    \"title\": \"%{smart_count} fichier |||| %{smart_count} fichiers\",\n    \"to\": \"Enregistrer dans :\",\n    \"action\": \"Enregistrer\",\n    \"cancel\": \"Annuler\",\n    \"success\": \"%{smart_count} fichier enregistré |||| %{smart_count} fichiers enregistrés\",\n    \"error\": \"Une erreur s'est produite. Merci de recommencer. \"\n  },\n  \"FileOpenerExternal\": {\n    \"fileNotFoundError\": \"Erreur : fichier non trouvé\"\n  },\n  \"TOS\": {\n    \"updated\": {\n      \"title\": \"Du nouveau avec le RGPD !\",\n      \"detail\": \"Dans le cadre du Règlement Général de la Protection des Données (RGPD), [nos CGU sont actualisées](%{link}) et s’appliquent pour vous à partir du 25 mai 2018.\",\n      \"cta\": \"Accepter les CGU et continuer\",\n      \"disconnect\": \"Refuser et se déconnecter\",\n      \"error\": \"Une erreur est survenue, merci de réessayer plus tard\"\n    }\n  },\n  \"manifest\": {\n    \"permissions\": {\n      \"contacts\": {\n        \"description\": \"Utilisé pour partager des éléments à vos contacts\"\n      },\n      \"groups\": {\n        \"description\": \"Utilisé pour partager des éléments à vos groupes\"\n      }\n    }\n  },\n  \"models\": {\n    \"contact\": {\n      \"defaultDisplayName\": \"Anonyme\"\n    }\n  },\n  \"Scan\": {\n    \"none\": \"Aucune\",\n    \"scan_a_doc\": \"Numériser un doc\",\n    \"save_doc\": \"Enregistrer le document\",\n    \"filename\": \"Nom du fichier\",\n    \"save\": \"Sauvegarder\",\n    \"cancel\": \"Annuler\",\n    \"qualify\": \"Qualifier\",\n    \"requalify\": \"Requalifier\",\n    \"apply\": \"Appliquer\",\n    \"error\": {\n      \"offline\": \"Vous êtes actuellement déconnecté, vous ne pouvez donc pas utiliser cette fonctionnalité. Connectez-vous à internet et recommencez. \",\n      \"uploading\": \"Vous avez déjà un fichier en cours de téléchargement. Attendez la fin et recommencez.\",\n      \"generic\": \"Un problème est survenu. Veuillez réessayer. \"\n    },\n    \"successful\": {\n      \"qualified_ok\": \"Fichier qualifié avec succès !\"\n    }\n  },\n  \"History\": {\n    \"description\": \"Les 20 dernières versions de vos fichiers sont conservées automatiquement. Sélectionnez une version pour la télécharger.\",\n    \"current_version\": \"Version actuelle\",\n    \"loading\": \"Chargement...\",\n    \"noFileVersionEnabled\": \"Nouveauté : votre Twake pourra prochainement archiver les dernières modifications d'un fichier pour ne plus jamais risquer de les perdre\"\n  },\n  \"External\": {\n    \"redirection\": {\n      \"title\": \"Redirection\",\n      \"text\": \"Vous êtes sur le point d'être redirigé... \",\n      \"error\": \"Erreur pendant la redirection. Généralement cela signifie que le contenu du fichier n'est pas dans le bon format. \"\n    }\n  },\n  \"RenameModal\": {\n    \"title\": \"Renommer\",\n    \"description\": \"Vous êtes sur le point de changer l'extension du fichier. Voulez-vous continuer ? \",\n    \"continue\": \"Continuer\",\n    \"cancel\": \"Annuler\"\n  },\n  \"Shortcut\": {\n    \"title_modal\": \"Créer un raccourci\",\n    \"filename\": \"Nom du fichier\",\n    \"url\": \"URL\",\n    \"cancel\": \"Annuler\",\n    \"create\": \"Créer\",\n    \"created\": \"Le raccourci a été créé\",\n    \"errored\": \"Une erreur s'est produite\",\n    \"filename_error_ends\": \"Le nom du fichier doit se terminer par .url\",\n    \"needs_info\": \"Un raccourci a besoin d'un nom et d'une URL\",\n    \"url_badformat\": \"L'URL saisie n'est pas dans le bon format\"\n  },\n  \"OnlyOffice\": {\n    \"Error\": {\n      \"title\": \"Quelque chose n'a pas fonctionné\",\n      \"text\": \"Essayez de recharger la page s'il vous plaît\"\n    },\n    \"readOnly\": {\n      \"title\": \"Lecture seule\",\n      \"tooltip\": \"Vous êtes uniquement autorisé à visualiser ce document. Contactez le propriétaire pour obtenir des droits d'écriture.\"\n    },\n    \"createFileName\": {\n      \"text\": \"Nouveau document texte\",\n      \"spreadsheet\": \"Nouvelle feuille de calcul\",\n      \"slide\": \"Nouvelle présentation\"\n    },\n    \"toolbar\": {\n      \"goToHome\": \"Aller à l'accueil\"\n    },\n    \"actions\": {\n      \"edit\": \"Modifier\",\n      \"validate\": \"Valider\"\n    },\n    \"tooltip\": {\n      \"title\": \"Modifier le document\",\n      \"text\": \"Le document est actuellement en lecture seule, Vous pouvez le modifier en cliquant ici.\",\n      \"actions\": {\n        \"ok\": \"Ok\",\n        \"hide\": \"Ne plus afficher\"\n      }\n    }\n  },\n  \"Migration\": {\n    \"title\": \"Mettre à jour Twake Drive\",\n    \"content\": \"Twake Drive doit être mis à jour afin d'améliorer ses performances. Cela peut prendre jusqu'à plusieurs minutes durant lesquelles vous ne pourrez pas utiliser l'application. Souhaitez-vous le faire maintenant ? Si vous refusez, nous vous redemanderons la prochaine fois.\",\n    \"confirm\": \"Ok, c'est parti !\",\n    \"cancel\": \"Non, pas maintenant\"\n  },\n  \"searchbar\": {\n    \"placeholder\": \"Rechercher\",\n    \"empty\": \"Aucun résultat trouvé pour la requête \\\"%{query}\\\"\"\n  },\n  \"button\": {\n    \"back\": \"Retour\",\n    \"add\": \"Ajouter\",\n    \"create\": \"Créer\"\n  },\n  \"search\": {\n    \"action\": \"Rechercher\",\n    \"empty\": {\n      \"title\": \"Aucun résultat\",\n      \"subtitle\": \"Aucun résultat trouvé pour la requête \\\"%{query}\\\"\"\n    }\n  },\n  \"PushBanner\": {\n    \"quota\": {\n      \"text\": \"Vous n'avez presque plus d'espace de stockage. Si vous atteignez la limite, vous ne pourrez plus ajouter de fichiers. Vous pouvez supprimer des fichiers, vider votre corbeille ou changer d'offre.\",\n      \"actions\": {\n        \"first\": \"J'ai compris\",\n        \"second\": \"Voir les offres\"\n      }\n    }\n  },\n  \"FileDivergedModal\": {\n    \"title\": \"Quelqu’un a modifié ce fichier\",\n    \"content\": \"Quelqu’un a modifié le contenu de ce fichier pendant que vous l'éditiez. Vous pouvez récupérer ces changements ou continuer votre édition sur un nouveau fichier.\",\n    \"confirm\": \"Continuer d'éditer\",\n    \"cancel\": \"Voir les changements\",\n    \"error\": \"Une erreur est survenue, merci de réessayer.\",\n    \"confirmReload\": {\n      \"title\": \"Voir les changements\",\n      \"content\": \"En accédant au nouveau fichier, vos modifications seront annulées.\",\n      \"cancel\": \"Annuler\",\n      \"confirm\": \"Ok, j’ai compris\"\n    },\n    \"viewMode\": {\n      \"title\": \"Quelqu’un a modifié ce fichier\",\n      \"content\": \"Quelqu’un a modifié le contenu de ce fichier. Vous pouvez récupérer ces changements.\",\n      \"confirm\": \"Voir les changements\"\n    }\n  },\n  \"FileDeletedModal\": {\n    \"title\": \"Quelqu’un a supprimé ce fichier\",\n    \"content\": \"Quelqu’un a supprimé ce fichier pendant que vous l'éditiez. Vous pouvez arrêter vos modifications ou restaurer ce fichier pour continuer vos modifications.\",\n    \"confirm\": \"Restaurer le fichier\",\n    \"cancel\": \"Annuler l'édition\",\n    \"error\": \"Une erreur est survenue, merci de réessayer.\"\n  },\n  \"TrashedBanner\": {\n    \"text\": \"Cet élément est dans la corbeille\",\n    \"destroy\": \"Supprimer définitivement\",\n    \"restore\": \"Restaurer\",\n    \"restoreSuccess\": \"L’élément a bien été restauré\",\n    \"restoreError\": \"Une erreur est survenue, merci de réessayer.\",\n    \"destroySuccess\": \"L’élément a bien été supprimé\"\n  },\n  \"MigrationProgressBanner\": {\n    \"title\": \"Migration depuis Nextcloud en cours\",\n    \"percent\": \"%{percent}% terminé\",\n    \"importing\": \"Importation de %{count} fichiers depuis Nextcloud ...\",\n    \"cancel\": \"Annuler\",\n    \"done\": {\n      \"title\": \"Migration terminée !\",\n      \"body\": \"%{count} fichiers importés avec succès depuis Nextcloud\"\n    }\n  },\n  \"EntriesType\": {\n    \"file\": \"fichier |||| fichiers\",\n    \"directory\": \"dossier |||| dossiers\",\n    \"element\": \"élément |||| éléments\"\n  },\n  \"NotFound\": {\n    \"title\": \"L’élément est introuvable\",\n    \"text\": \"Nous n’avons trouvé aucun élément à cette adresse. Il s’agit peut-être d’une erreur de frappe.\"\n  },\n  \"NextcloudBreadcrumb\": {\n    \"root\": \"Drive partagés\",\n    \"trash\": \"Corbeille\"\n  },\n  \"NextcloudToolbar\": {\n    \"share\": \"Partager\"\n  },\n  \"NextcloudDeleteConfirm\": {\n    \"title\": \"Supprimer %{filename} ? |||| Supprimer %{smart_count} %{type} ?\",\n    \"trash\": \"Cet élément sera déplacé dans la corbeille de Nextcloud. |||| Ces éléments seront déplacés dans la corbeille de Nextcloud.\",\n    \"restore\": \"Vous pouvez toujours le restaurer quand vous voulez depuis Nextcloud.\",\n    \"error\": \"Une erreur est survenue, merci de réessayer.\",\n    \"cancel\": \"Annuler\",\n    \"delete\": \"Supprimer\"\n  },\n  \"FileName\": {\n    \"sharedDrive\": \"Drives\",\n    \"trash\": \"Corbeille\"\n  },\n  \"NextcloudBanner\": {\n    \"title\": \"Les éléments ci-dessous sont affichés depuis un drive NextCloud et ne sont pas stockés dans votre Twake.\"\n  },\n  \"favorites\": {\n    \"label\": {\n      \"add\": \"Ajouter aux favoris\",\n      \"addMobile\": \"Favoris\",\n      \"remove\": \"Retirer des favoris\"\n    },\n    \"error\": \"Une erreur est survenue, merci de réessayer.\",\n    \"success\": {\n      \"add\": \"%{filename} a été ajouté aux favoris |||| Ces éléments ont été ajoutés aux favoris\",\n      \"remove\": \"%{filename} a été retiré des favoris |||| Ces éléments ont été retirés des favoris\"\n    }\n  },\n  \"TrashToolbar\": {\n    \"emptyTrash\": \"Vider la corbeille\"\n  },\n  \"RestoreNextcloudFile\": {\n    \"label\": \"Restaurer\",\n    \"success\": \"L'élément a bien été restauré\",\n    \"error\": \"Une erreur est survenue, merci de réessayer.\"\n  },\n  \"actions\": {\n    \"details\": \"Détails\",\n    \"infos\": \"Détails et qualification\",\n    \"infosMobile\": \"Détails\",\n    \"duplicateTo\": {\n      \"label\": \"Dupliquer vers…\"\n    },\n    \"duplicateToMobile\": {\n      \"label\": \"Dupliquer\"\n    },\n    \"personalizeFolder\": {\n      \"label\": \"Personnaliser le dossier\"\n    },\n    \"summariseByAI\": \"Résumer\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"Personnaliser le dossier\",\n    \"description\": \"Choisissez une couleur spécifique pour votre dossier\",\n    \"cancel\": \"Annuler\",\n    \"apply\": \"Appliquer\",\n    \"error\": \"Une erreur est survenue, merci de réessayer.\",\n    \"tabs\": {\n      \"colors\": \"Couleurs\",\n      \"icons\": \"Icônes\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"Récents\",\n      \"chooseCustomIcon\": \"Choisir une icône personnalisée\"\n    }\n  },\n  \"DuplicateModal\": {\n    \"subTitle\": \"Dupliquer vers :\",\n    \"confirmLabel\": \"Dupliquer ici\",\n    \"success\": \"%{fileName} a été dupliqué dans %{destinationName}. |||| %{smart_count} éléments ont été dupliqués dans %{destinationName}.\",\n    \"error\": \"Une erreur est survenue, merci de réessayer.\"\n  },\n  \"OpenFolderButton\": {\n    \"label\": \"Ouvrir le dossier\"\n  },\n  \"LastUpdate\": {\n    \"titleFormat\": \"dd LLLL yyyy, HH:MM\"\n  },\n  \"AddMenu\": {\n    \"readOnlyFolder\": \"Ce dossier est en lecture seule. Vous ne pouvez pas effectuer cette action.\"\n  },\n  \"PublicNoteRedirect\": {\n    \"error\": {\n      \"title\": \"Impossible d'accéder au document\",\n      \"subtitle\": \"Le lien de partage semble manquant ou invalide. Merci de demander au propriétaire du document de vérifier les accès\"\n    }\n  },\n  \"antivirus\": {\n    \"infectedFile\": \"Ce fichier est infecté par un virus\",\n    \"popover\": {\n      \"title\": \"Le téléchargement et le partage sont bloqués pour des raisons de sécurité\",\n      \"description\": \"Le système Twake a détecté un virus\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/locales/index.js",
    "content": "import { getI18n } from 'twake-i18n'\n\nimport ar from './ar.json'\nimport de from './de.json'\nimport en from './en.json'\nimport es from './es.json'\nimport fr from './fr.json'\nimport it from './it.json'\nimport ja from './ja.json'\nimport ko from './ko.json'\nimport nl from './nl.json'\nimport nl_NL from './nl_NL.json'\nimport pl from './pl.json'\nimport ru from './ru.json'\nimport zh_CN from './zh_CN.json'\nimport zh_TW from './zh_TW.json'\n\nexport const locales = {\n  ar,\n  de,\n  en,\n  es,\n  fr,\n  it,\n  ja,\n  ko,\n  nl,\n  nl_NL,\n  pl,\n  ru,\n  zh_CN,\n  zh_TW\n}\n\nexport const getDriveI18n = () => getI18n(undefined, lang => locales[lang])\n"
  },
  {
    "path": "src/locales/it.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"Drive\",\n    \"item_recent\": \"Recenti\",\n    \"item_sharings\": \"Condivisioni\",\n    \"item_shared\": \"Condiviso da me\",\n    \"item_activity\": \"Attività\",\n    \"item_trash\": \"Cestino\",\n    \"item_settings\": \"Impostazioni\",\n    \"item_collect\": \"Amministrativo\",\n    \"btn-client\": \"Ottieni Twake Drive per desktop\",\n    \"btn-client-web\": \"Ottieni Twake\",\n    \"btn-client-mobile\": \"Ottieni %{name} sul tuo telefono!\",\n    \"banner-txt-client\": \"Ottieni %{name} per Desktop e sincronizza i tuoi file in modo sicuro per renderli accessibili in qualsiasi momento.\",\n    \"banner-btn-client\": \"Scarica\",\n    \"link-client\": \"https://cozy.io/en/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.drive.mobile\",\n    \"link-client-ios\": \"https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8\",\n    \"link-client-web\": \"https://cozy.io/try-it\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"Drive\",\n    \"title_recent\": \"Recenti\",\n    \"title_sharings\": \"Condivisioni\",\n    \"title_shared\": \"Condiviso da me\",\n    \"title_activity\": \"Attività\",\n    \"title_trash\": \"Cestino\",\n    \"label\": \"Mostra percorso\"\n  },\n  \"Toolbar\": {\n    \"more\": \"Altro\"\n  },\n  \"toolbar\": {\n    \"menu_upload\": \"Carica files\",\n    \"item_more\": \"Altro\",\n    \"menu_new_folder\": \"Cartella\",\n    \"menu_select\": \"Seleziona oggetti\",\n    \"menu_share_folder\": \"Condividi cartella\",\n    \"menu_download\": \"Scarica\",\n    \"menu_sync_cozy\": \"Sincronizza con il mio Twake\",\n    \"add_to_mine\": \"Aggiungi al mio Twake\",\n    \"menu_download_folder\": \"Cartella download\",\n    \"menu_download_file\": \"Scarica questo file\",\n    \"menu_create_note\": \"Nota\",\n    \"menu_create_shortcut\": \"Collegamento\",\n    \"empty_trash\": \"Svuota cestino\",\n    \"share\": \"Condividi\",\n    \"trash\": \"Rimuovi\",\n    \"delete_shared_drive\": \"Elimina unità condivisa\",\n    \"leave\": \"Lascia la cartella condivisa ed eliminala\",\n    \"menu_add\": \"Aggiungi\",\n    \"menu_create\": \"Creare\",\n    \"menu_onlyOffice\": {\n      \"text\": \"Documento di testo\",\n      \"spreadsheet\": \"Foglio di calcolo\",\n      \"slide\": \"Presentazione\"\n    },\n    \"select_all\": \"Seleziona tutto\",\n    \"clear_selection\": \"Cancella selezione\",\n    \"sharings_tab_all\": \"Tutti\",\n    \"sharings_tab_drives\": \"Unità\"\n  },\n  \"Share\": {\n    \"create-cozy\": \"Crea il mio Twake\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"cta\": \"Condividi\",\n      \"title\": \"Condividi\",\n      \"details\": {\n        \"title\": \"Dettagli condivisione\",\n        \"createdAt\": \"Il %{date}\",\n        \"ro\": \"Può visualizzare\",\n        \"rw\": \"Può modificare\",\n        \"desc\": {\n          \"ro\": \"È possibile visualizzare, scaricare e aggiungere questo contenuto al proprio Twake. Riceverao gli aggiornamenti da parte del proprietario, ma non potrai aggiornarli tu stesso.\",\n          \"rw\": \"Puoi visualizzare, aggiornare, cancellare e aggiungere questo contenuto al tuo Twake. Gli aggiornamenti apportati saranno visibili anche agli altri Twake.\"\n        }\n      },\n      \"sharedByMe\": \"Condiviso da me\",\n      \"sharedWithMe\": \"Condiviso con me\",\n      \"sharedBy\": \"Condiviso da %{name}\",\n      \"shareByLink\": {\n        \"subtitle\": \"Tramite link pubblico\",\n        \"desc\": \"Chiunque abbia il link fornito può vedere e scaricare i tuoi file.\",\n        \"creating\": \"Creazione del link...\",\n        \"copy\": \"Copia link\",\n        \"copied\": \"Il link è stato copiato negli appunti\",\n        \"failed\": \"Impossibile copiare negli appunti\"\n      },\n      \"shareByEmail\": {\n        \"subtitle\": \"Tramite email\",\n        \"email\": \"A:\",\n        \"emailPlaceholder\": \"Inserire l'indirizzo e-mail o il nome del destinatario\",\n        \"send\": \"Invia\",\n        \"genericSuccess\": \"Hai inviato un invito a %{count} contatti.\",\n        \"success\": \"Hai inviato un invito a %{email}.\",\n        \"comingsoon\": \"Prossimamente! Potrai condividere documenti e foto con un solo clic con la tua famiglia, i tuoi amici e persino i tuoi colleghi. Non preoccuparti, ti faremo sapere quando sarà disponibile!\",\n        \"onlyByLink\": \"Questo %{tipo} può essere condiviso solo tramite link, perché\",\n        \"type\": {\n          \"file\": \"file\",\n          \"folder\": \"cartella\"\n        },\n        \"hasSharedParent\": \"ha un genitore condiviso\",\n        \"hasSharedChild\": \"contiene un elemento condiviso\"\n      },\n      \"revoke\": {\n        \"title\": \"Rimuovi dalla condivisione\",\n        \"desc\": \"Questo contatto manterrà una copia, ma le modifiche non saranno più sincronizzate.\",\n        \"success\": \"Hai rimosso questo file condiviso da %{email}.\"\n      },\n      \"revokeSelf\": {\n        \"title\": \"Rimuovimi dalla condivisione\",\n        \"desc\": \"Il contenuto viene mantenuto, ma non verrà più aggiornato sul tuo Twake.\",\n        \"success\": \"Sei stato rimosso da questa condivisione.\"\n      },\n      \"sharingLink\": {\n        \"title\": \"Link per la condivisione\",\n        \"copy\": \"Copia\",\n        \"copied\": \"Copiato\"\n      },\n      \"whoHasAccess\": {\n        \"title\": \"1 persona ha accesso |||| %{smart_count} persone hanno accesso\"\n      },\n      \"protectedShare\": {\n        \"title\": \"In arrivo!\",\n        \"desc\": \"Condividi qualsiasi cosa via e-mail con la tua famiglia e i tuoi amici!\"\n      },\n      \"close\": \"Chiudi\",\n      \"gettingLink\": \"Ottenendo il tuo link\",\n      \"specialCase\": {\n        \"isInSharedFolder\": \"è in una cartella condivisa\",\n        \"hasSharedFolder\": \"contiene una cartella condivisa\"\n      }\n    }\n  },\n  \"table\": {\n    \"head_name\": \"Nome\",\n    \"head_update\": \"Ultimo aggiornamento\",\n    \"head_size\": \"Dimensione\",\n    \"row_update_format\": \"LLL d, yyyy\",\n    \"row_update_format_full\": \"LLLL d, yyyy\",\n    \"row_read_only\": \"Condividi (Solo Lettura)\",\n    \"row_read_write\": \"Condividi (Lettura e Scrittura)\",\n    \"row_size_symbols\": {\n      \"B\": \"B\",\n      \"KB\": \"KB\",\n      \"MB\": \"MB\",\n      \"GB\": \"GB\",\n      \"TB\": \"TB\",\n      \"PB\": \"PB\",\n      \"EB\": \"EB\",\n      \"ZB\": \"ZB\",\n      \"YB\": \"YB\"\n    },\n    \"load_more\": \"Carica altro\",\n    \"mobile\": {\n      \"head_name_asc\": \"A-Z\",\n      \"head_name_desc\": \"Z-A\"\n    }\n  },\n  \"Storage\": {\n    \"title\": \"Archiviazione\",\n    \"availability\": \"%{smart_count} GB disponibili\",\n    \"increase\": \"Aumenta il tuo spazio\"\n  },\n  \"SelectionBar\": {\n    \"share\": \"Condividi\",\n    \"download\": \"Scarica\",\n    \"trash\": \"Rimuovi\",\n    \"destroy\": \"Elimina permanentemente\",\n    \"rename\": \"Rinomina\",\n    \"restore\": \"Ripristina\",\n    \"close\": \"Chiudi\",\n    \"openWith\": \"Apri con...\"\n  },\n  \"DeleteConfirm\": {\n    \"cancel\": \"Annulla\",\n    \"delete\": \"Rimuovi\"\n  },\n  \"emptytrashconfirmation\": {\n    \"title\": \"Eliminare permanentemente?\",\n    \"forbidden\": \"Non sarai più in grado di accedere a questi file.\",\n    \"cancel\": \"Annulla\",\n    \"delete\": \"Elimina tutto\"\n  },\n  \"DestroyConfirm\": {\n    \"title\": \"Eliminare permanentemente?\",\n    \"cancel\": \"Annulla\",\n    \"delete\": \"Elimina permanentemente\"\n  },\n  \"quotaalert\": {\n    \"confirm\": \"OK\"\n  },\n  \"loading\": {\n    \"message\": \"Caricamento\"\n  },\n  \"empty\": {\n    \"title\": \"Non hai nessun file in questa cartella.\"\n  },\n  \"error\": {\n    \"button\": {\n      \"reload\": \"Aggiorna adesso\"\n    },\n    \"download_file\": {\n      \"offline\": \"Devi essere connesso per scaricare questo file\"\n    }\n  },\n  \"alert\": {\n    \"could_not_open_file\": \"Il file non può essere aperto\",\n    \"empty_trash_success\": \"Il cestino è stato svuotato\",\n    \"folder_generic\": \"Si è verificato un errore, per favore riprova.\",\n    \"offline\": \"Questa caratteristica non è disponibile offline.\",\n    \"item_copied\": \"1 elemento copiato\",\n    \"items_copied\": \"%{count} elementi copiati\",\n    \"item_cut\": \"1 elemento tagliato\",\n    \"items_cut\": \"%{count} elementi tagliati\",\n    \"item_moved\": \"1 elemento è stato spostato\",\n    \"items_moved\": \"%{count} elementi sono stati spostati\",\n    \"item_pasted\": \"1 elemento è stato spostato\",\n    \"items_pasted\": \"%{count} elementi sono stati spostati\",\n    \"copy_files_only\": \"Non è possibile copiare le cartelle\",\n    \"copy_not_allowed\": \"L'operazione di copia non è consentita in questa vista.\",\n    \"cut_not_allowed\": \"L'operazione di taglio non è consentita in questa vista.\",\n    \"paste_error\": \"Si è verificato un errore durante l'incollaggio dei file\",\n    \"paste_failed\": \"Incollaggio dei file fallito\",\n    \"paste_sharing_error\": \"Impossibile incollare i file a causa di restrizioni di condivisione. Si prega di utilizzare l'azione Sposta invece.\",\n    \"paste_same_folder_skipped\": \"Impossibile spostare gli elementi nella stessa cartella in cui si trovano già.\",\n    \"paste_not_allowed\": \"Non puoi incollare in questa cartella\",\n    \"cannot_move_shared_drive\": \"Non puoi spostare la cartella dell'unità condivisa\",\n    \"cannot_copy_shared_drive\": \"Non puoi copiare la cartella dell’unità condivisa\"\n  },\n  \"UploadQueue\": {\n    \"close\": \"chiudi\",\n    \"item\": {\n      \"pending\": \"In attesa\"\n    }\n  },\n  \"Viewer\": {\n    \"close\": \"Chiudi\",\n    \"noviewer\": {\n      \"download\": \"Scarica questo file\",\n      \"openWith\": \"Apri con...\"\n    },\n    \"actions\": {\n      \"download\": \"Scarica\"\n    },\n    \"loading\": {\n      \"retry\": \"Riprova\"\n    }\n  },\n  \"ImportToDrive\": {\n    \"action\": \"Salva\"\n  },\n  \"FileOpenerExternal\": {\n    \"fileNotFoundError\": \"Errore: file non trovato\"\n  },\n  \"models\": {\n    \"contact\": {\n      \"defaultDisplayName\": \"Anonimo\"\n    }\n  },\n  \"Scan\": {\n    \"save_doc\": \"Salva il documento\",\n    \"save\": \"Salva\"\n  },\n  \"History\": {\n    \"current_version\": \"Versione corrente\",\n    \"loading\": \"Caricamento...\"\n  },\n  \"External\": {\n    \"redirection\": {\n      \"text\": \"Stai per essere reindirizzato...\"\n    }\n  },\n  \"RenameModal\": {\n    \"title\": \"Rinomina\"\n  },\n  \"Shortcut\": {\n    \"url\": \"URL\",\n    \"errored\": \"Si è verificato un errore\"\n  },\n  \"OnlyOffice\": {\n    \"readOnly\": {\n      \"title\": \"Sola lettura\"\n    },\n    \"createFileName\": {\n      \"text\": \"Nuovo documento di testo\",\n      \"spreadsheet\": \"Nuovo foglio di calcolo\",\n      \"slide\": \"Nuova presentazione\"\n    }\n  },\n  \"searchbar\": {\n    \"placeholder\": \"Cerca\",\n    \"empty\": \"Nessun risultato trovato per la richiesta “%{query}”\"\n  },\n  \"search\": {\n    \"empty\": {\n      \"subtitle\": \"Nessun risultato trovato per la richiesta “%{query}”\"\n    }\n  },\n  \"actions\": {\n    \"details\": \"Dettagli\",\n    \"personalizeFolder\": {\n      \"label\": \"Personalizza cartella\"\n    },\n    \"summariseByAI\": \"Riassumere\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"Personalizza cartella\",\n    \"description\": \"Scegli un colore specifico per la tua cartella\",\n    \"cancel\": \"Annulla\",\n    \"apply\": \"Applica\",\n    \"error\": \"Si è verificato un errore, riprova.\",\n    \"tabs\": {\n      \"colors\": \"Colori\",\n      \"icons\": \"Icone\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"Recenti\",\n      \"chooseCustomIcon\": \"Scegli un'icona personalizzata\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/locales/ja.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"ドライブ\",\n    \"item_recent\": \"最近使用したファイル\",\n    \"item_sharings\": \"共有\",\n    \"item_shared\": \"自分が共有した\",\n    \"item_activity\": \"アクティビティ\",\n    \"item_trash\": \"ゴミ箱\",\n    \"item_settings\": \"設定\",\n    \"item_collect\": \"管理\",\n    \"btn-client\": \"デスクトップ用 Twake ドライブを入手\",\n    \"btn-client-web\": \"Twake を入手する\",\n    \"btn-client-mobile\": \"お使いのモバイルで %{name} ドライブを入手しましょう!\",\n    \"banner-txt-client\": \"デスクトップ用 %{name} ドライブを入手して、ファイルに安全に同期していつでもアクセスできるようにしましょう。\",\n    \"banner-btn-client\": \"ダウンロード\",\n    \"link-client\": \"https://cozy.io/en/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.drive.mobile\",\n    \"link-client-ios\": \"https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8\",\n    \"link-client-web\": \"https://cozy.io/try-it\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"ドライブ\",\n    \"title_recent\": \"最近使用したファイル\",\n    \"title_sharings\": \"共有\",\n    \"title_shared\": \"自分が共有した\",\n    \"title_activity\": \"アクティビティ\",\n    \"title_trash\": \"ゴミ箱\"\n  },\n  \"Toolbar\": {\n    \"more\": \"さらに\"\n  },\n  \"toolbar\": {\n    \"item_more\": \"さらに\",\n    \"menu_select\": \"アイテムを選択\",\n    \"menu_share_folder\": \"フォルダーを共有\",\n    \"menu_download_folder\": \"ダウンロードフォルダー\",\n    \"menu_download_file\": \"このファイルをダウンロード\",\n    \"empty_trash\": \"ゴミ箱を空にする\",\n    \"share\": \"共有\",\n    \"trash\": \"削除\",\n    \"delete_shared_drive\": \"共有ドライブを削除\",\n    \"leave\": \"共有されたフォルダーから離れて削除する\",\n    \"select_all\": \"すべて選択\",\n    \"clear_selection\": \"選択をクリア\",\n    \"sharings_tab_all\": \"すべて\",\n    \"sharings_tab_drives\": \"ドライブ\"\n  },\n  \"Share\": {\n    \"create-cozy\": \"自分の Twake を作成する\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"cta\": \"共有\",\n      \"title\": \"共有\",\n      \"details\": {\n        \"title\": \"共有の詳細\",\n        \"createdAt\": \"日付 %{date}\",\n        \"ro\": \"読み取り可能\",\n        \"rw\": \"変更可能\",\n        \"desc\": {\n          \"ro\": \"このコンテンツを表示、ダウンロード、あなたの Twake に追加することができます。 所有者による更新を受け取りますが、あなた自身でこのコンテンツを更新することはできません。\",\n          \"rw\": \"このコンテンツを表示、更新、削除、あなたの Twake に追加することができます。 行った更新は他の Twake でも見られます。\"\n        }\n      },\n      \"sharedByMe\": \"自分が共有した\",\n      \"sharedWithMe\": \"自分と共有\",\n      \"sharedBy\": \"%{name} が共有しました\",\n      \"shareByLink\": {\n        \"subtitle\": \"公開リンクで\",\n        \"desc\": \"提供されたリンクを持つ人は、誰でもあなたのファイルを見たりダウンロードしたりすることができます。\",\n        \"creating\": \"リンクを作成中...\",\n        \"copy\": \"リンクをコピー\",\n        \"copied\": \"リンクをクリップボードにコピーしました\",\n        \"failed\": \"クリップボードにコピーできません\"\n      },\n      \"shareByEmail\": {\n        \"subtitle\": \"メールで\",\n        \"email\": \"宛先:\",\n        \"emailPlaceholder\": \"メールアドレスまたは受信者の名前を入力してください\",\n        \"send\": \"送信\",\n        \"genericSuccess\": \"%{count} 連絡先に招待状を送信しました。\",\n        \"success\": \"招待状を %{email} に送信しました。\",\n        \"comingsoon\": \"まもなく登場します! 家族や友達、さらには同僚ともワンクリックで文書や写真を共有できます。 ご心配なく、準備ができたらお知らせします!\",\n        \"onlyByLink\": \"この %{type} はリンクを共有することだけできます。\",\n        \"type\": {\n          \"file\": \"ファイル\",\n          \"folder\": \"フォルダー\"\n        },\n        \"hasSharedParent\": \"共有した親があります\",\n        \"hasSharedChild\": \"共有した要素を含みます\"\n      },\n      \"revoke\": {\n        \"title\": \"共有から削除\",\n        \"desc\": \"この連絡先はコピーを保存しますが、変更は同期されません。\",\n        \"success\": \"この共有済ファイルを %{email} から削除しました。\"\n      },\n      \"revokeSelf\": {\n        \"title\": \"共有から自分を削除\",\n        \"desc\": \"コンテンツを保存しますが、もうお使いの Twake 間で更新されません。\",\n        \"success\": \"この共有から削除されました。\"\n      },\n      \"sharingLink\": {\n        \"title\": \"共有するリンク\",\n        \"copy\": \"コピー\",\n        \"copied\": \"コピーしました\"\n      },\n      \"whoHasAccess\": {\n        \"title\": \"1 人がアクセスできます |||| %{smart_count} 人がアクセスできます\"\n      },\n      \"protectedShare\": {\n        \"title\": \"まもなく登場します!\",\n        \"desc\": \"あなたの家族や友達とメールで何でも共有してください!\"\n      },\n      \"close\": \"閉じる\",\n      \"gettingLink\": \"リンクの取得中...\",\n      \"error\": {\n        \"generic\": \"ファイル共有リンクの作成中にエラーが発生しました。もう一度やり直してください。\",\n        \"revoke\": \"エラーが発生しました。 できるだけ早くこの問題を解決できるように、私たちにご連絡ください。\"\n      },\n      \"specialCase\": {\n        \"base\": \"この %{type} は共有できませんが、リンクできます\",\n        \"isInSharedFolder\": \"共有フォルダーの中にあります\",\n        \"hasSharedFolder\": \"共有フォルダーを含みます\"\n      }\n    },\n    \"viewer-fallback\": \"ファイルのダウンロードが始まったら、これを閉じることができます。\",\n    \"dropzone\": {\n      \"teaser\": \"ファイルをドラッグ＆ドロップするとアップロードします:\",\n      \"noFolderSupport\": \"現在お使いのブラウザーでフォルダーのドラッグ＆ドロップはサポートされていません。 手動でファイルをアップロードしてください。\"\n    }\n  },\n  \"table\": {\n    \"head_name\": \"名前\",\n    \"head_update\": \"最終更新\",\n    \"head_size\": \"サイズ\",\n    \"head_thumbnail_size\": \"サムネイルのサイズを切り替え\",\n    \"row_update_format\": \"yyyy/LL/dd\",\n    \"row_update_format_full\": \"yyyy/LL/dd\",\n    \"row_read_only\": \"共有 (読み取り専用)\",\n    \"row_read_write\": \"共有 (読み書き)\",\n    \"row_size_symbols\": {\n      \"B\": \"B\",\n      \"KB\": \"KB\",\n      \"MB\": \"MB\",\n      \"GB\": \"GB\",\n      \"TB\": \"TB\",\n      \"PB\": \"PB\",\n      \"EB\": \"EB\",\n      \"ZB\": \"ZB\",\n      \"YB\": \"YB\"\n    },\n    \"load_more\": \"さらに読み込む\",\n    \"mobile\": {\n      \"head_name_asc\": \"A-Z\",\n      \"head_name_desc\": \"Z-A\",\n      \"head_updated_at_asc\": \"古いものが先頭\",\n      \"head_updated_at_desc\": \"最近使用したものが先頭\",\n      \"head_size_asc\": \"小さいものが先頭\",\n      \"head_size_desc\": \"大きなものが先頭\"\n    }\n  },\n  \"Storage\": {\n    \"title\": \"ストレージ\",\n    \"availability\": \"%{smart_count} GB 利用可能\",\n    \"increase\": \"スペースを増やす\"\n  },\n  \"SelectionBar\": {\n    \"selected_count\": \"アイテム選択 |||| アイテム選択\",\n    \"share\": \"共有\",\n    \"download\": \"ダウンロード\",\n    \"trash\": \"削除\",\n    \"destroy\": \"完全に削除\",\n    \"rename\": \"名前の変更\",\n    \"restore\": \"復元\",\n    \"close\": \"閉じる\",\n    \"moveto\": \"移動…\",\n    \"moveto_mobile\": \"移動\",\n    \"phone-download\": \"オフラインで利用可能にする\",\n    \"qualify\": \"分類\",\n    \"history\": \"履歴\"\n  },\n  \"DeleteConfirm\": {\n    \"title\": \"この要素を削除しますか? |||| これらの要素を削除しますか?\",\n    \"trash\": \"ゴミ箱に移動されます。 |||| ゴミ箱に移動されます。\",\n    \"restore\": \"いつでも元に戻すことができます。 |||| いつでも元に戻すことができます。\",\n    \"referenced\": \"選択範囲内の一部のファイルがフォトアルバムに関連しています。それらはゴミ箱に移動すると、削除されます。\",\n    \"cancel\": \"キャンセル\",\n    \"delete\": \"削除\"\n  },\n  \"emptytrashconfirmation\": {\n    \"title\": \"完全に削除しますか?\",\n    \"forbidden\": \"これらのファイルにもうアクセスすることはできません。\",\n    \"restore\": \"バックアップを作成していない場合、これらのファイルを復元することはできません。\",\n    \"cancel\": \"キャンセル\",\n    \"delete\": \"すべて削除\"\n  },\n  \"DestroyConfirm\": {\n    \"title\": \"完全に削除しますか?\",\n    \"forbidden\": \"このファイルにもうアクセスすることはできません。 |||| これらのファイルにもうアクセスすることはできません。\",\n    \"restore\": \"バックアップを作成していない場合、このファイルを復元することはできません。 |||| バックアップを作成していない場合、これらのファイルを復元することはできません。\",\n    \"cancel\": \"キャンセル\",\n    \"delete\": \"完全に削除\"\n  },\n  \"quotaalert\": {\n    \"title\": \"お使いのディスク容量が一杯です :(\",\n    \"desc\": \"ファイルを再度アップロードする前に、ファイルを削除するか、ゴミ箱を空にするか、ディスク容量を増やしてください。\",\n    \"confirm\": \"OK\",\n    \"increase\": \"ディスク容量を増やす\"\n  },\n  \"loading\": {\n    \"message\": \"読み込み中\"\n  },\n  \"empty\": {\n    \"title\": \"このフォルダーにファイルはありません。\",\n    \"text\": \"コンピューター上のファイルを選択するか、ここにドラッグアンドドロップしてください。\",\n    \"mobile_text\": \"デバイス上のファイルを選択してください。\",\n    \"trash_title\": \"削除されたファイルはありません。\",\n    \"trash_text\": \"不要になったファイルをゴミ箱に移動し、アイテムを完全に削除するとストレージページを解放します。\"\n  },\n  \"error\": {\n    \"open_folder\": \"フォルダーを開くときに何か問題が発生しました。\",\n    \"button\": {\n      \"reload\": \"今すぐ更新\"\n    },\n    \"download_file\": {\n      \"offline\": \"このファイルをダウンロードするには接続している必要があります\",\n      \"missing\": \"このファイルが見つかりません\"\n    }\n  },\n  \"Error\": {\n    \"public_unshared_title\": \"申し訳ありません。このリンクはもう利用できません。\",\n    \"public_unshared_text\": \"このリンクは有効期限が切れているか、所有者によって削除されています。 見つからないことを彼または彼女に知らせてください!\",\n    \"generic\": \"エラーが発生しました。数分待ってからもう一度やり直してください。\"\n  },\n  \"alert\": {\n    \"could_not_open_file\": \"ファイルを開くことができません\",\n    \"try_again\": \"エラーが発生しました。しばらくしてからもう一度やり直してください。\",\n    \"restore_file_success\": \"選択を正常に復元しました。\",\n    \"trash_file_success\": \"選択をゴミ箱に移動しました。\",\n    \"destroy_file_success\": \"選択を完全に削除しました。\",\n    \"empty_trash_progress\": \"ゴミ箱を空にしています。これは数分かかることがあります。\",\n    \"empty_trash_success\": \"ゴミ箱を空にしました。\",\n    \"folder_name\": \"要素 %{folderName} はすでに存在します。新しい名前を選んでください。\",\n    \"folder_generic\": \"エラーが発生しました。もう一度やり直してください。\",\n    \"folder_abort\": \"保存したい場合、新しいフォルダーに名前を追加する必要があります。 情報は保存されていません。\",\n    \"offline\": \"この機能はオフラインでは利用できません。\",\n    \"preparing\": \"ファイルを準備しています…\",\n    \"item_copied\": \"1個のアイテムをコピーしました\",\n    \"items_copied\": \"%{count}個のアイテムをコピーしました\",\n    \"item_cut\": \"1個のアイテムを切り取りました\",\n    \"items_cut\": \"%{count}個のアイテムを切り取りました\",\n    \"item_moved\": \"1個のアイテムが移動されました\",\n    \"items_moved\": \"%{count}個のアイテムが移動されました\",\n    \"item_pasted\": \"1個のアイテムが移動されました\",\n    \"items_pasted\": \"%{count}個のアイテムが移動されました\",\n    \"copy_files_only\": \"フォルダーはコピーできません\",\n    \"copy_not_allowed\": \"このビューではコピー操作は許可されていません。\",\n    \"cut_not_allowed\": \"このビューでは切り取り操作は許可されていません。\",\n    \"paste_error\": \"ファイルの貼り付け中にエラーが発生しました\",\n    \"paste_failed\": \"ファイルの貼り付けに失敗しました\",\n    \"paste_sharing_error\": \"共有制限のためファイルを貼り付けることができません。代わりに移動アクションを使用してください。\",\n    \"paste_same_folder_skipped\": \"アイテムを既に存在する同じフォルダに移動することはできません。\",\n    \"paste_not_allowed\": \"このフォルダに貼り付けることはできません\",\n    \"cannot_move_shared_drive\": \"共有ドライブフォルダを移動することはできません\",\n    \"cannot_copy_shared_drive\": \"共有ドライブのフォルダをコピーできません\"\n  },\n  \"upload\": {\n    \"label\": \"アップロード\",\n    \"alert\": {\n      \"network\": \"現在オフラインです。 接続したらもう一度やり直してください。\"\n    }\n  },\n  \"intents\": {\n    \"alert\": {\n      \"error\": \"ファイルを自動的にアップロードできません。アップロードメニューで手動でアップロードしてください。\"\n    },\n    \"picker\": {\n      \"select\": \"選択\",\n      \"cancel\": \"キャンセル\",\n      \"new_folder\": \"新しいフォルダー\",\n      \"instructions\": \"対象を選択\"\n    }\n  },\n  \"UploadQueue\": {\n    \"header\": \"%{smart_count} 枚の写真を Twake ドライブにアップロード中 |||| %{smart_count} 枚の写真を Twake ドライブにアップロード中\",\n    \"header_mobile\": \"アップロード中 %{done} / %{total}\",\n    \"header_done\": \"%{done} / %{total} を正常にアップロードしました\",\n    \"close\": \"閉じる\",\n    \"item\": {\n      \"pending\": \"保留\"\n    }\n  },\n  \"Viewer\": {\n    \"close\": \"閉じる\",\n    \"noviewer\": {\n      \"download\": \"このファイルをダウンロード\",\n      \"openWith\": \"...で開く\",\n      \"cta\": {\n        \"saveTime\": \"時間を節約しましょう!\",\n        \"installDesktop\": \"コンピュータに同期ツールをインストール\",\n        \"accessFiles\": \"自分のコンピュータ上のファイルに直接アクセス\"\n      }\n    },\n    \"actions\": {\n      \"download\": \"ダウンロード\"\n    },\n    \"loading\": {\n      \"error\": \"このファイルを読み込めませんでした。 現在、インターネットに接続していますか?\",\n      \"retry\": \"再試行\"\n    },\n    \"error\": {\n      \"generic\": \"このファイルを開くときにエラーが発生しました。もう一度やり直してください。\",\n      \"noNetwork\": \"現在オフラインです。\"\n    }\n  },\n  \"Move\": {\n    \"to\": \"移動先:\",\n    \"action\": \"移動\",\n    \"cancel\": \"キャンセル\",\n    \"modalTitle\": \"移動\",\n    \"title\": \"%{smart_count} アイテム |||| %{smart_count} アイテム\",\n    \"success\": \"%{subject} を %{target} に移動しました。 |||| %{smart_count} アイテムを %{target} に移動しました。\",\n    \"error\": \"このアイテムを移動中に問題が発生しました。後でもう一度やり直してください。 |||| これらのアイテムを移動中に問題が発生しました。後でもう一度やり直してください。\",\n    \"cancelled\": \"%{subject} を元の場所にもどしました。 |||| %{smart_count} アイテムを元の場所に戻しました。\",\n    \"cancelledWithRestoreErrors\": \"%{subject} を元の場所に戻しましたが、ゴミ箱からファイルを復元する時にエラーが発生しました。 |||| %{smart_count} 件を元の場所に戻しましたが、ゴミ箱からファイルを復元する時に %{restoreErrorsCount} エラーが発生しました。\",\n    \"cancelled_error\": \"アイテムを戻す際にエラーが発生しました。 |||| アイテムを戻す際にエラーが発生しました。\"\n  },\n  \"ImportToDrive\": {\n    \"title\": \"%{smart_count} アイテム |||| %{smart_count} アイテム\",\n    \"to\": \"保存先:\",\n    \"action\": \"保存\",\n    \"cancel\": \"キャンセル\",\n    \"success\": \"%{smart_count} 保存済ファイル |||| %{smart_count} 保存済ファイル\",\n    \"error\": \"何か問題が発生しました。もう一度やり直してください\"\n  },\n  \"FileOpenerExternal\": {\n    \"fileNotFoundError\": \"エラー: ファイルが見つかりません\"\n  },\n  \"TOS\": {\n    \"updated\": {\n      \"title\": \"GDPR が現実のものになります !\",\n      \"detail\": \"一般データ保護規則に従って、[利用規約が更新されました](%{link}) 、2018 年 5 月 25 日にすべての Twake ユーザーに適用されます。\",\n      \"cta\": \"利用規約に同意して続行する\",\n      \"disconnect\": \"拒否して切断する\",\n      \"error\": \"問題が発生しました。後でもう一度やり直してください\"\n    }\n  },\n  \"manifest\": {\n    \"permissions\": {\n      \"contacts\": {\n        \"description\": \"連絡先とファイルを共有するために必要です\"\n      },\n      \"groups\": {\n        \"description\": \"グループとファイルを共有するために必要です\"\n      }\n    }\n  },\n  \"models\": {\n    \"contact\": {\n      \"defaultDisplayName\": \"匿名\"\n    }\n  },\n  \"Scan\": {\n    \"scan_a_doc\": \"ドキュメントをスキャン\",\n    \"save_doc\": \"ドキュメントを保存\",\n    \"filename\": \"ファイル名\",\n    \"save\": \"保存\",\n    \"cancel\": \"キャンセル\",\n    \"qualify\": \"分類\",\n    \"apply\": \"適用\",\n    \"error\": {\n      \"offline\": \"現在オフラインのため、この機能は使用できません。 後でもう一度やり直してください\",\n      \"uploading\": \"すでにファイルをアップロードしています。 このアップロードが終了するまで待ってから、もう一度やり直してください。\",\n      \"generic\": \"何か問題が発生しました。もう一度やり直してください。\"\n    },\n    \"successful\": {\n      \"qualified_ok\": \"ファイルの分類ができました!\"\n    }\n  },\n  \"History\": {\n    \"description\": \"ファイルの最新の20バージョンが自動的に保存されます。 ダウンロードするバージョンを選択してください。\",\n    \"current_version\": \"現在のバージョン\",\n    \"loading\": \"読み込んでいます...\",\n    \"noFileVersionEnabled\": \"Twake は、ファイルの最後の変更をすぐにアーカイブできるので、もう失う危険はありません。\"\n  },\n  \"External\": {\n    \"redirection\": {\n      \"title\": \"リダイレクト\",\n      \"text\": \"リダイレクトしています…\",\n      \"error\": \"リダイレクト中にエラーが発生しました。 通常、これはファイルの内容が正しい形式ではないことを意味します。\"\n    }\n  },\n  \"RenameModal\": {\n    \"title\": \"名前の変更\",\n    \"description\": \"ファイルの拡張子を変更しようとしています。 続行してもよろしいですか?\",\n    \"continue\": \"続行\",\n    \"cancel\": \"キャンセル\"\n  },\n  \"Shortcut\": {\n    \"title_modal\": \"ショートカットの作成\",\n    \"filename\": \"ファイル名\",\n    \"url\": \"URL\",\n    \"cancel\": \"キャンセル\",\n    \"create\": \"作成\",\n    \"created\": \"ショートカットを作成しました\",\n    \"errored\": \"エラーが発生しました\",\n    \"filename_error_ends\": \"名前は .url で終了する必要があります\",\n    \"needs_info\": \"ショートカットは URL とファイル名である必要があります\",\n    \"url_badformat\": \"URL が正しい形式ではありません\"\n  },\n  \"searchbar\": {\n    \"placeholder\": \"検索します\",\n    \"empty\": \"問い合わせ “%{query}” の結果が見つかりません\"\n  },\n  \"actions\": {\n    \"details\": \"詳細\",\n    \"personalizeFolder\": {\n      \"label\": \"フォルダをカスタマイズ\"\n    },\n    \"summariseByAI\": \"要約\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"フォルダをカスタマイズ\",\n    \"description\": \"フォルダの特定の色を選択します\",\n    \"cancel\": \"キャンセル\",\n    \"apply\": \"適用\",\n    \"error\": \"エラーが発生しました。もう一度お試しください。\",\n    \"tabs\": {\n      \"colors\": \"色\",\n      \"icons\": \"アイコン\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"最近使用\",\n      \"chooseCustomIcon\": \"カスタムアイコンを選択\"\n    }\n  }\n}"
  },
  {
    "path": "src/locales/ko.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"드라이브\",\n    \"item_recent\": \"최근\",\n    \"item_activity\": \"활동\",\n    \"item_settings\": \"설정\",\n    \"banner-btn-client\": \"다운로드\",\n    \"link-client\": \"https://cozy.io/en/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.drive.mobile\",\n    \"link-client-ios\": \"https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8\",\n    \"link-client-web\": \"https://cozy.io/try-it\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"드라이브\",\n    \"title_recent\": \"최근\",\n    \"title_activity\": \"활동\"\n  },\n  \"Toolbar\": {\n    \"more\": \"더보기\"\n  },\n  \"toolbar\": {\n    \"item_more\": \"더보기\",\n    \"menu_new_folder\": \"폴더\",\n    \"menu_download\": \"다운로드\",\n    \"sharings_tab_all\": \"모두\",\n    \"sharings_tab_drives\": \"드라이브\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"shareByLink\": {\n        \"creating\": \"링크 생성\",\n        \"copy\": \"링크 복사\",\n        \"copied\": \"링크를 클립보드에 복사했습니다.\"\n      },\n      \"sharingLink\": {\n        \"copy\": \"복사\"\n      },\n      \"error\": {\n        \"generic\": \"파일 공유 링크 생성 중에 오류가 발생했습니다. 나중에 다시 시도하세요.\",\n        \"revoke\": \"이런, 오류가 발생했습니다. 저희에게 알려 주시면 최대한 빨리 문제를 해결하겠습니다.\"\n      }\n    }\n  },\n  \"table\": {\n    \"head_name\": \"이름\",\n    \"head_size\": \"크기\"\n  },\n  \"error\": {\n    \"open_folder\": \"폴더를 여는 동안 문제가 발생했습니다.\",\n    \"button\": {\n      \"reload\": \"지금 새로고침\"\n    }\n  },\n  \"Error\": {\n    \"public_unshared_title\": \"죄송합니다. 이 링크는 더이상 이용할 수 없습니다.\",\n    \"generic\": \"오류가 발생했습니다. 나중에 다시 시도하세요.\"\n  },\n  \"alert\": {\n    \"could_not_open_file\": \"이 파일을 열 수 없습니다.\",\n    \"item_copied\": \"1개 항목이 복사되었습니다\",\n    \"items_copied\": \"%{count}개 항목이 복사되었습니다\",\n    \"item_cut\": \"1개 항목이 잘라내기되었습니다\",\n    \"items_cut\": \"%{count}개 항목이 잘라내기되었습니다\",\n    \"item_moved\": \"1개 항목이 이동되었습니다\",\n    \"items_moved\": \"%{count}개 항목이 이동되었습니다\",\n    \"items_pasted\": \"%{count}개 항목이 이동되었습니다\",\n    \"copy_files_only\": \"폴더는 복사할 수 없습니다\",\n    \"copy_not_allowed\": \"이 보기에서는 복사 작업이 허용되지 않습니다.\",\n    \"cut_not_allowed\": \"이 보기에서는 잘라내기 작업이 허용되지 않습니다.\",\n    \"paste_error\": \"파일 붙여넣기 중 오류가 발생했습니다\",\n    \"paste_failed\": \"파일 붙여넣기에 실패했습니다\",\n    \"paste_sharing_error\": \"공유 제한으로 인해 파일을 붙여넣을 수 없습니다. 대신 이동 작업을 사용하십시오.\",\n    \"paste_same_folder_skipped\": \"항목을 이미 있는 동일한 폴더로 이동할 수 없습니다.\",\n    \"paste_not_allowed\": \"이 폴더에 붙여넣을 수 없습니다\",\n    \"cannot_move_shared_drive\": \"공유 드라이브 폴더를 이동할 수 없습니다\",\n    \"cannot_copy_shared_drive\": \"공유 드라이브 폴더를 복사할 수 없습니다\"\n  },\n  \"History\": {\n    \"loading\": \"불러오는 중...\"\n  },\n  \"OnlyOffice\": {\n    \"createFileName\": {\n      \"text\": \"새 문서 만들기\",\n      \"spreadsheet\": \"새 스프레드시트 만들기\",\n      \"slide\": \"새 프레젠테이션 만들기\"\n    }\n  },\n  \"actions\": {\n    \"details\": \"세부 정보\",\n    \"personalizeFolder\": {\n      \"label\": \"폴더 개인화\"\n    },\n    \"summariseByAI\": \"요약\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"폴더 개인화\",\n    \"description\": \"폴더의 특정 색상을 선택하세요\",\n    \"cancel\": \"취소\",\n    \"apply\": \"적용\",\n    \"error\": \"오류가 발생했습니다. 다시 시도해 주세요.\",\n    \"tabs\": {\n      \"colors\": \"색상\",\n      \"icons\": \"아이콘\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"최근 항목\",\n      \"chooseCustomIcon\": \"사용자 지정 아이콘 선택\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/locales/nl.json",
    "content": "{\n  \"breadcrumb\": {\n    \"title_drive\": \"Schijf\",\n    \"title_recent\": \"Recent\",\n    \"title_shared\": \"Gedeeld door mij\",\n    \"title_activity\": \"Activiteit\",\n    \"title_trash\": \"Prullenbak\"\n  },\n  \"toolbar\": {\n    \"menu_select\": \"Selecteer items\",\n    \"empty_trash\": \"Leeg de prullenbak\",\n    \"select_all\": \"Alles selecteren\",\n    \"delete_shared_drive\": \"Gedeelde schijf verwijderen\",\n    \"sharings_tab_all\": \"Alles\",\n    \"sharings_tab_drives\": \"Stations\"\n  },\n  \"table\": {\n    \"head_name\": \"Naam\",\n    \"head_update\": \"Laatst bijgewerkt\",\n    \"head_size\": \"Grootte\",\n    \"row_read_only\": \"Delen (alleen lezen)\",\n    \"row_read_write\": \"Delen (Lezen en schrijven)\",\n    \"row_size_symbols\": {\n      \"B\": \"B\",\n      \"KB\": \"KB\",\n      \"MB\": \"MB\",\n      \"GB\": \"GB\",\n      \"TB\": \"TB\",\n      \"PB\": \"PB\",\n      \"EB\": \"EB\",\n      \"ZB\": \"ZB\",\n      \"YB\": \"YB\"\n    }\n  },\n  \"DeleteConfirm\": {\n    \"title\": \"Verwijder dit element? |||| Verwijder deze elementen?\",\n    \"trash\": \"Het zal worden verplaatst naar de Prullenbak. ||| Ze zullen worden verplaatst naar de Prullenbak.\",\n    \"restore\": \"Je kunt het nog steeds terughalen als je wilt. |||| Je kunt ze nog steeds terughalen als je wilt.\",\n    \"cancel\": \"Annuleren\",\n    \"delete\": \"Verwijderen\"\n  },\n  \"emptytrashconfirmation\": {\n    \"title\": \"Permanent verwijderen?\",\n    \"forbidden\": \"Je kunt deze bestanden niet meer benaderen.\",\n    \"restore\": \"Als je geen back-up gemaakt hebt, kun je deze bestanden niet meer terugzetten.\",\n    \"cancel\": \"Annuleren\",\n    \"delete\": \"Verwijder alles\"\n  },\n  \"DestroyConfirm\": {\n    \"title\": \"Verwijder permanent?\",\n    \"forbidden\": \"Je kunt dit bestand net meer benaderen. |||| Je kunt deze bestanden niet meer benaderen.\",\n    \"restore\": \"Als je geen back-up gemaakt hebt, kun je dit bestand niet meer terugzetten. |||| Als je geen back-up gemaakt hebt, kun je deze bestanden niet meer terugzetten.\",\n    \"cancel\": \"Annuleren\",\n    \"delete\": \"Verwijder permanent\"\n  },\n  \"quotaalert\": {\n    \"title\": \"Jouw schijfruimte is vol :(\",\n    \"confirm\": \"OK\"\n  },\n  \"loading\": {\n    \"message\": \"Laden\"\n  },\n  \"empty\": {\n    \"title\": \"Er staan geen bestanden in deze map.\"\n  },\n  \"error\": {\n    \"open_folder\": \"Er is is fout gegaan bij het openen van de map.\",\n    \"button\": {\n      \"reload\": \"Nu verversen\"\n    },\n    \"download_file\": {\n      \"offline\": \"Je moet verbonden zijn om dit  bestand te downloaden\",\n      \"missing\": \"Dit bestand bestaat niet\"\n    }\n  },\n  \"alert\": {\n    \"try_again\": \"Er is een fout opgetreden, probeer het later nog eens.\",\n    \"restore_file_success\": \"De selectie is succesvol herstelt.\",\n    \"trash_file_success\": \"De selectie is verplaatst naar de Prullenbak.\",\n    \"destroy_file_success\": \"De selectie is permanent verwijderd.\",\n    \"folder_name\": \"Het element %{foldername} bestaat al, kies een andere naam.\",\n    \"folder_generic\": \"Er is een fout opgetreden, probeer het opnieuw.\",\n    \"folder_abort\": \"Je moet de nieuwe map een naam geven als je het wilt opslaan. De gegevens zijn niet opgeslagen.\",\n    \"offline\": \"Deze mogelijkheid is niet beschikbaar offline.\",\n    \"item_copied\": \"1 item gekopieerd\",\n    \"items_copied\": \"%{count} items gekopieerd\",\n    \"item_cut\": \"1 item geknipt\",\n    \"items_cut\": \"%{count} items geknipt\",\n    \"item_moved\": \"1 item is verplaatst\",\n    \"items_moved\": \"%{count} items zijn verplaatst\",\n    \"item_pasted\": \"1 item is verplaatst\",\n    \"items_pasted\": \"%{count} items zijn verplaatst\",\n    \"copy_files_only\": \"Mappen kunnen niet worden gekopieerd\",\n    \"copy_not_allowed\": \"Kopieerbewerking is niet toegestaan in deze weergave.\",\n    \"cut_not_allowed\": \"Knipbewerking is niet toegestaan in deze weergave.\",\n    \"paste_error\": \"Er is een fout opgetreden bij het plakken van bestanden\",\n    \"paste_failed\": \"Plakken van bestanden mislukt\",\n    \"paste_sharing_error\": \"Kan bestanden niet plakken vanwege deelbeperkingen. Gebruik in plaats daarvan de actie Verplaatsen.\",\n    \"paste_same_folder_skipped\": \"Kan items niet verplaatsen naar dezelfde map waar ze al in staan.\",\n    \"paste_not_allowed\": \"Je kunt niet plakken in deze map\",\n    \"cannot_move_shared_drive\": \"Je kunt de gedeelde schijfmap niet verplaatsen\",\n    \"cannot_copy_shared_drive\": \"Je kunt de gedeelde schijfmap niet kopiëren\"\n  },\n  \"actions\": {\n    \"details\": \"Details\",\n    \"personalizeFolder\": {\n      \"label\": \"Map personaliseren\"\n    },\n    \"summariseByAI\": \"Samenvatten\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"Map personaliseren\",\n    \"description\": \"Kies een specifieke kleur voor uw map\",\n    \"cancel\": \"Annuleren\",\n    \"apply\": \"Toepassen\",\n    \"error\": \"Er is een fout opgetreden, probeer het opnieuw.\",\n    \"tabs\": {\n      \"colors\": \"Kleuren\",\n      \"icons\": \"Pictogrammen\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"Recente\",\n      \"chooseCustomIcon\": \"Kies een aangepast pictogram\"\n    }\n  }\n}"
  },
  {
    "path": "src/locales/nl_NL.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"Schijf\",\n    \"item_recent\": \"Recent\",\n    \"item_sharings\": \"Gedeelde items\",\n    \"item_shared\": \"Door mij gedeeld\",\n    \"item_activity\": \"Activiteit\",\n    \"item_trash\": \"Prullenbak\",\n    \"item_settings\": \"Instellingen\",\n    \"item_collect\": \"Administratie\",\n    \"btn-client\": \"Download Twake Schijf voor je computer\",\n    \"btn-client-web\": \"Download Twake\",\n    \"btn-client-mobile\": \"Download %{name} Schijf op je telefoon!\",\n    \"banner-txt-client\": \"Download %{name} Schijf voor je computer en synchroniseer veilig je bestanden om ze overal beschikbaar te maken.\",\n    \"banner-btn-client\": \"Downloaden\",\n    \"link-client\": \"https://cozy.io/en/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.drive.mobile\",\n    \"link-client-ios\": \"https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8\",\n    \"link-client-web\": \"https://cozy.io/try-it\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"Schijf\",\n    \"title_recent\": \"Recent\",\n    \"title_sharings\": \"Gedeelde items\",\n    \"title_shared\": \"Door mij gedeeld\",\n    \"title_activity\": \"Activiteit\",\n    \"title_trash\": \"Prullenbak\",\n    \"label\": \"Locatie tonen\"\n  },\n  \"Toolbar\": {\n    \"more\": \"Meer\"\n  },\n  \"toolbar\": {\n    \"menu_upload\": \"Bestanden uploaden\",\n    \"item_more\": \"Meer\",\n    \"menu_new_folder\": \"Map\",\n    \"menu_select\": \"Items selecteren\",\n    \"menu_share_folder\": \"Map delen\",\n    \"menu_download\": \"Downloaden\",\n    \"menu_sync_cozy\": \"Synchroniseren naar mijn Twake\",\n    \"add_to_mine\": \"Toevoegen aan mijn Twake\",\n    \"menu_download_folder\": \"Map downloaden\",\n    \"menu_download_file\": \"Download dit bestand\",\n    \"menu_create_note\": \"Notitie\",\n    \"menu_create_shortcut\": \"Snelkoppeling\",\n    \"empty_trash\": \"Prullenbak legen\",\n    \"share\": \"Delen\",\n    \"trash\": \"Verwijderen\",\n    \"delete_shared_drive\": \"Gedeelde schijf verwijderen\",\n    \"leave\": \"Gedeelde map verlaten en verwijderen\",\n    \"menu_add\": \"Toevoegen\",\n    \"menu_create\": \"Creëren\",\n    \"menu_add_item\": \"Item toevoegen\",\n    \"menu_onlyOffice\": {\n      \"text\": \"Tekstdocumen\",\n      \"spreadsheet\": \"Werkblad\",\n      \"slide\": \"Presentatie\"\n    },\n    \"select_all\": \"Alles selecteren\",\n    \"sharings_tab_all\": \"Alles\",\n    \"sharings_tab_drives\": \"Stations\"\n  },\n  \"Share\": {\n    \"create-cozy\": \"Maak mijn Twake\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"cta\": \"Delen\",\n      \"title\": \"Delen\",\n      \"details\": {\n        \"title\": \"Deelinformatie\",\n        \"createdAt\": \"Op %{date}\",\n        \"ro\": \"Mag bekijken\",\n        \"rw\": \"Mag wijzigen\",\n        \"desc\": {\n          \"ro\": \"Je kunt deze inhoud bekijken, downloaden op en toevoegen aan je Twake. Je ontvangt bijgewerkte versies van de eigenaar, maar je kunt zelfs niks aanpassen.\",\n          \"rw\": \"Je kunt deze inhoud bekijken, downloaden op en toevoegen aan je Twake. Bijgewerkte versies zijn beschikbaar op andere Cozies.\"\n        }\n      },\n      \"sharedByMe\": \"Door mij gedeeld\",\n      \"sharedWithMe\": \"Met mij gedeeld\",\n      \"sharedBy\": \"Gedeeld door %{name}\",\n      \"shareByLink\": {\n        \"subtitle\": \"Via openbare link\",\n        \"desc\": \"Iedereen die de link heeft kan je bestanden bekijken en downloaden.\",\n        \"creating\": \"Bezig met maken van je link…\",\n        \"copy\": \"Link kopiëren\",\n        \"copied\": \"Link is gekopieerd naar het klembord\",\n        \"failed\": \"Kan niet kopiëren naar klembord\"\n      },\n      \"shareByEmail\": {\n        \"subtitle\": \"Via e-mail\",\n        \"email\": \"Aan:\",\n        \"emailPlaceholder\": \"Voer het e-mailadres of de naam in van de ontvanger\",\n        \"send\": \"Versturen\",\n        \"genericSuccess\": \"Je hebt een uitnodiging verstuurd aan %{count} contactpersonen.\",\n        \"success\": \"Je hebt een uitnodiging verstuurd aan %{email}.\",\n        \"comingsoon\": \"Binnenkort kun je documenten en foto's met één klik delen met je familie, vrienden en zelfs met je collega's! Geen zorgen, we laten je weten wanneer dit beschikbaar is.\",\n        \"onlyByLink\": \"Dit %{type} kan niet worden gedeeld via een link omdat het\",\n        \"type\": {\n          \"file\": \"bestand\",\n          \"folder\": \"map\"\n        },\n        \"hasSharedParent\": \"een gedeelde bovenliggende map bevat\",\n        \"hasSharedChild\": \"een gedeeld itembevat\"\n      },\n      \"revoke\": {\n        \"title\": \"Verwijderen uit gedeelde items\",\n        \"desc\": \"De contactpersoon behoudt de kopie, maar aanpassingen worden niet langer gesynchroniseerd.\",\n        \"success\": \"Je hebt dit gedeelde bestand verwijderd uit %{email}.\"\n      },\n      \"revokeSelf\": {\n        \"title\": \"Verwijder mij uit gedeelde items\",\n        \"desc\": \"De inhoud blijft bewaard, maar wordt niet langer bijgewerkt tussen je Twake-apparaten.\",\n        \"success\": \"Je bent verwijderd uit deze gedeelde items.\"\n      },\n      \"sharingLink\": {\n        \"title\": \"Link om te delen\",\n        \"copy\": \"Kopiëren\",\n        \"copied\": \"Gekopieerd\"\n      },\n      \"whoHasAccess\": {\n        \"title\": \"1 persoon heeft toegang |||| %{smart_count} personen hebben toegang\"\n      },\n      \"protectedShare\": {\n        \"title\": \"Binnenkort!\",\n        \"desc\": \"Deel van alles via e-mail met je familie en vrienden!\"\n      },\n      \"close\": \"Sluiten\",\n      \"gettingLink\": \"Bezig met ophalen van je link…\",\n      \"error\": {\n        \"generic\": \"Er is een fout opgetreden tijdens het creëren van de link. Probeer het opnieuw.\",\n        \"revoke\": \"Oeps, er is een fout opgetreden. Neem contact met ons op zodat we het probleem z.s.m. kunnen verhelpen.\"\n      },\n      \"specialCase\": {\n        \"base\": \"Dit %{type} kan niet worden gedeeld met een link omdat het\",\n        \"isInSharedFolder\": \"zich bevindt in een gedeelde map\",\n        \"hasSharedFolder\": \"een gedeelde map bevat\"\n      }\n    },\n    \"viewer-fallback\": \"Je kunt dit sluiten zodra het downloaden is gestart.\",\n    \"dropzone\": {\n      \"teaser\": \"Versleep bestanden om ze te uploaden naar:\",\n      \"noFolderSupport\": \"Je browser heeft geen ondersteuning voor slepen-en-neerzetten. Upload de bestanden handmatig.\"\n    }\n  },\n  \"table\": {\n    \"head_name\": \"Naam\",\n    \"head_update\": \"Laatst bijgewerkt\",\n    \"head_size\": \"Grootte\",\n    \"head_status\": \"Delen\",\n    \"head_thumbnail_size\": \"Miniatuurgrootte aanpassen\",\n    \"row_update_format\": \"LLL d, yyyy\",\n    \"row_update_format_full\": \"LLLL d, yyyy\",\n    \"row_read_only\": \"Delen (alleen-lezen)\",\n    \"row_read_write\": \"Delen (lezen en bewerken)\",\n    \"row_size_symbols\": {\n      \"B\": \"B\",\n      \"KB\": \"KB\",\n      \"MB\": \"MB\",\n      \"GB\": \"GB\",\n      \"TB\": \"TB\",\n      \"PB\": \"PB\",\n      \"EB\": \"EB\",\n      \"ZB\": \"ZB\",\n      \"YB\": \"YB\"\n    },\n    \"load_more\": \"Meer laden\",\n    \"mobile\": {\n      \"head_name_asc\": \"A-Z\",\n      \"head_name_desc\": \"Z-A\",\n      \"head_updated_at_asc\": \"Oudste eerst\",\n      \"head_updated_at_desc\": \"Recentste eerst\",\n      \"head_size_asc\": \"Kleinste eerst\",\n      \"head_size_desc\": \"Grootste eerst\"\n    },\n    \"tooltip\": {\n      \"carbonCopy\": {\n        \"title\": \"Carbon Copy\",\n        \"caption\": \"Toont aan of het document ‘authentiek en origineel’ is verklaard door Twake Workplace, de hoster van je Twake. Het kan namelijk zo zijn dat de claim is gedaan door een externe partij zonder enige aanpassing.\"\n      },\n      \"electronicSafe\": {\n        \"title\": \"Elektronische kluis\",\n        \"caption\": \"Toont aan of het oorspronkelijke document veilig is opgeslagen in je persoonlijke digitale kluis, voorzien van alle bijbehorende certificeringen en 50 jaar garantie.\"\n      }\n    }\n  },\n  \"Storage\": {\n    \"title\": \"Opslag\",\n    \"availability\": \"%{smart_count} GB beschikbaar\",\n    \"increase\": \"Vergroot je ruimte\"\n  },\n  \"SelectionBar\": {\n    \"selected_count\": \"item geselecteerd |||| items geselecteerd\",\n    \"share\": \"Delen\",\n    \"download\": \"Downloaden\",\n    \"trash\": \"Verwijderen\",\n    \"destroy\": \"Permanent verwijderen\",\n    \"rename\": \"Naam wijzigen\",\n    \"restore\": \"Herstellen\",\n    \"close\": \"Sluiten\",\n    \"openWith\": \"Openen met…\",\n    \"applePreview\": \"Apple-voorbeeld\",\n    \"forward\": \"Vooruit\",\n    \"forwardTo\": \"Doorsturen naar…\",\n    \"moveto\": \"Verplaatsen naar…\",\n    \"moveto_mobile\": \"Verplaatsen\",\n    \"phone-download\": \"Offline beschikbaar maken\",\n    \"qualify\": \"Categoriseren\",\n    \"history\": \"Geschiedenis\",\n    \"more\": \"Meer\"\n  },\n  \"DeleteConfirm\": {\n    \"title\": \"Dit item verwijderen? |||| Deze items verwijderen?\",\n    \"trash\": \"Het wordt verplaatst naar de prullenbak. |||| Ze worden verplaatst naar de prullenbak.\",\n    \"restore\": \"Je kunt het ten allen tijde herstellen. |||| Je kunt ze ten allen tijde herstellen.\",\n    \"link\": \"De gedeelde link komt te vervallen\",\n    \"referenced\": \"Sommige geselecteerde bestanden horen bij een foto-album. Als je doorgaat, dan worden ze verwijderd.\",\n    \"cancel\": \"Annuleren\",\n    \"delete\": \"Verwijderen\"\n  },\n  \"emptytrashconfirmation\": {\n    \"title\": \"Permanent verwijderen?\",\n    \"forbidden\": \"Je hebt dan  geen toegang meer tot deze bestanden.\",\n    \"restore\": \"Je kunt deze bestanden niet herstellen als je geen back-up hebt gemaakt.\",\n    \"cancel\": \"Annuleren\",\n    \"delete\": \"Alles verwijderen\"\n  },\n  \"DestroyConfirm\": {\n    \"title\": \"Permanent verwijderen?\",\n    \"forbidden\": \"Je hebt dan geen toegang meer tot dit bestand. |||| Je hebt dat geen toegang meer tot deze bestanden.\",\n    \"restore\": \"Je kunt dit bestand niet herstellen als je geen back-up hebt gemaakt. |||| Je kunt deze bestanden niet herstellen als je geen back-up hebt gemaakt.\",\n    \"cancel\": \"Annuleren\",\n    \"delete\": \"Permanent verwijderen\"\n  },\n  \"quotaalert\": {\n    \"title\": \"Je hebt geen vrije schijfruimte meer :(\",\n    \"desc\": \"Verwijder bestanden en leeg de prullenbak voordat je wéér probeert om bestanden te uploaden.\",\n    \"confirm\": \"Oké\",\n    \"increase\": \"Vergroot je schijfruimte\"\n  },\n  \"loading\": {\n    \"message\": \"Bezig met laden…\",\n    \"onlyOfficeCreateInProgress\": \"Bezig met aanmaken van huidig bestand…\"\n  },\n  \"empty\": {\n    \"title\": \"Deze map bevat geen bestanden.\",\n    \"text\": \"Selecteer bestanden op uw computer of sleep ze hierheen.\",\n    \"mobile_text\": \"Selecteer bestanden op je apparaat.\",\n    \"trash_title\": \"Je hebt geen verwijderde bestanden.\",\n    \"trash_text\": \"Verplaats bestanden die je niet langer nodig hebt naar de prullenbak en verwijder items permanent om ruimte vrij te maken.\"\n  },\n  \"error\": {\n    \"open_folder\": \"Er is iets misgegaan tijdens het openen van de map.\",\n    \"open_file\": \"Er is iets misgegaan tijdens het openen van het bestand.\",\n    \"button\": {\n      \"reload\": \"Nu herladen\"\n    },\n    \"download_file\": {\n      \"offline\": \"Je moet verbonden zijn om dit bestand te kunnen downloaden\",\n      \"missing\": \"Dit bestand ontbreekt\"\n    }\n  },\n  \"Error\": {\n    \"public_unshared_title\": \"Sorry, deze link niet langer beschikbaar.\",\n    \"public_unshared_text\": \"Deze link is verlopen of verwijderd door de eigenaar. Stel hem of haar hiervan op de hoogte!\",\n    \"generic\": \"Er is iets misgegaan. Wacht een paar minuten en probeer het opnieuw.\"\n  },\n  \"alert\": {\n    \"could_not_open_file\": \"Het bestand kan niet worden geopend\",\n    \"try_again\": \"Er is een fout opgetreden; probeer het later opnieuw.\",\n    \"restore_file_success\": \"De selectie is hersteld.\",\n    \"trash_file_success\": \"De selectie is verplaatst naar de prullenbak.\",\n    \"destroy_file_success\": \"De selectie is permanent verwijderd.\",\n    \"empty_trash_progress\": \"De prullenbak wordt geleegd; dit kan even duren.\",\n    \"empty_trash_success\": \"De prullenbak is geleegd.\",\n    \"folder_name\": \"‘%{folderName}’ bestaat al. Kies een andere naam.\",\n    \"file_name\": \"‘%{fileName}’ bestaat al. Kies een andere naam.\",\n    \"file_name_missing\": \"De bestandsnaam ontbreekt - geef een naam op.\",\n    \"file_name_illegal_name\": \"‘%{fileName}’ is ongeldig. Kies een andere naam.\",\n    \"file_name_illegal_characters\": \"%{fileName} bevat ongeldige tekens: %{characters}\",\n    \"folder_generic\": \"Er is een fout opgetreden; probeer het opnieuw.\",\n    \"folder_abort\": \"Als je je nieuwe map wilt opslaan, dan moet je deze een naam geven. Je informatie is niet opgeslagen.\",\n    \"offline\": \"Deze functie is niet offline beschikbaar.\",\n    \"preparing\": \"Bezig met voorbereiden van je bestanden…\",\n    \"item_copied\": \"1 item gekopieerd\",\n    \"items_copied\": \"%{count} items gekopieerd\",\n    \"item_cut\": \"1 item geknipt\",\n    \"items_cut\": \"%{count} items geknipt\",\n    \"item_moved\": \"1 item is verplaatst\",\n    \"items_moved\": \"%{count} items zijn verplaatst\",\n    \"item_pasted\": \"1 item is verplaatst\",\n    \"items_pasted\": \"%{count} items zijn verplaatst\",\n    \"copy_files_only\": \"Mappen kunnen niet worden gekopieerd\",\n    \"copy_not_allowed\": \"Kopieerbewerking is niet toegestaan in deze weergave.\",\n    \"cut_not_allowed\": \"Knipbewerking is niet toegestaan in deze weergave.\",\n    \"paste_error\": \"Er is een fout opgetreden bij het plakken van bestanden\",\n    \"paste_failed\": \"Plakken van bestanden mislukt\",\n    \"paste_sharing_error\": \"Kan bestanden niet plakken vanwege deelbeperkingen. Gebruik in plaats daarvan de actie Verplaatsen.\",\n    \"paste_same_folder_skipped\": \"Kan items niet verplaatsen naar dezelfde map waar ze al in staan.\",\n    \"paste_not_allowed\": \"U kunt niet plakken in deze map\",\n    \"cannot_move_shared_drive\": \"U kunt de gedeelde schijfmap niet verplaatsen\",\n    \"cannot_copy_shared_drive\": \"Je kunt de gedeelde schijfmap niet kopiëren\"\n  },\n  \"upload\": {\n    \"label\": \"Uploaden\",\n    \"alert\": {\n      \"network\": \"Je bent momenteel offline. Maak verbinding en probeer het opnieuw.\"\n    }\n  },\n  \"intents\": {\n    \"alert\": {\n      \"error\": \"Het bestand kan niet automatisch worden geüpload. Doe het handmatig via het uploadmenu.\"\n    },\n    \"picker\": {\n      \"select\": \"Selecteren\",\n      \"cancel\": \"Annuleren\",\n      \"new_folder\": \"Nieuwe map\",\n      \"instructions\": \"Kies een doel\"\n    }\n  },\n  \"UploadQueue\": {\n    \"header\": \"Bezig met uploaden van %{smart_count} foto naar Twake Drive |||| Bezig met uploaden van %{smart_count} foto's naar Twake Drive\",\n    \"header_mobile\": \"Bezig met uploaden - %{done} van %{total}…\",\n    \"header_done\": \"%{done} van de %{total} geüpload\",\n    \"close\": \"sluiten\",\n    \"item\": {\n      \"pending\": \"In wachtrij\"\n    }\n  },\n  \"Viewer\": {\n    \"close\": \"Sluiten\",\n    \"noviewer\": {\n      \"download\": \"Download dit bestand\",\n      \"openWith\": \"Openen met…\",\n      \"openInOnlyOffice\": \"Openen met OnlyOffice\",\n      \"cta\": {\n        \"saveTime\": \"Bespaar wat tijd!\",\n        \"installDesktop\": \"Installeer de synchronisatie-app op je computer\",\n        \"accessFiles\": \"Direct toegang tot je bestanden vanaf je computer\"\n      }\n    },\n    \"actions\": {\n      \"download\": \"Downloaden\",\n      \"forward\": \"Vooruit\"\n    },\n    \"loading\": {\n      \"error\": \"Dit bestand kan niet worden geladen. Ben je verbonden met het internet?\",\n      \"retry\": \"Opnieuw proberen\"\n    },\n    \"error\": {\n      \"noapp\": \"Er is geen app die dit bestand in behandeling kan nemen.\",\n      \"generic\": \"Er is een fout opgetreden tijdens het openen van dit bestand. Probeer het opnieuw.\",\n      \"noNetwork\": \"Je bent momenteel offline.\"\n    },\n    \"panel\": {\n      \"title\": \"Nuttige informatie\"\n    }\n  },\n  \"Move\": {\n    \"to\": \"Verplaatsen naar:\",\n    \"action\": \"Verplaatsen\",\n    \"cancel\": \"Annuleren\",\n    \"modalTitle\": \"Verplaatsen\",\n    \"title\": \"%{smart_count} item |||| %{smart_count} items\",\n    \"success\": \"%{subject} is verplaatst naar %{target}. ||| %{smart_count} items zijn verplaatst naar %{target}.\",\n    \"error\": \"Er is iets misgegaan tijdens het verplaatsen van dit item; probeer het later opnieuw. |||| Er is iets misgegaan tijdens het verplaatsen van deze items; probeer het later opnieuw.\",\n    \"cancelled\": \"%{subject} is teruggeplaatst op de oorspronkelijke locatie. ||| %{smart_count} items zijn teruggeplaatst op hun oorspronkelijke locatie.\",\n    \"cancelledWithRestoreErrors\": \"%{subject} is teruggeplaatst op de oorspronkelijke locatie, maar er is een fout opgetreden. ||| %{smart_count} items zijn teruggeplaatst op hun oorspronkelijke locatie, maar er zijn %{restoreErrorsCount} fouten opgetreden.\",\n    \"cancelled_error\": \"Sorry, er is iets misgegaan tijdens het terughalen van dit item. |||| Sorry, er is iets misgegaan tijdens terughalen van deze items.\",\n    \"multipleEntries\": \"%{smart_count} item |||| %{smart_count} items\",\n    \"outsideSharedFolder\": {\n      \"title\": \"Verplaatsen buiten de map ‘%{sharedFolder}’\",\n      \"cancel\": \"Annuleren\",\n      \"confirm\": \"Ik begrĳp het\"\n    }\n  },\n  \"ImportToDrive\": {\n    \"title\": \"%{smart_count} item |||| %{smart_count} items\",\n    \"to\": \"Opslaan in:\",\n    \"action\": \"Opslaan\",\n    \"cancel\": \"Annuleren\",\n    \"success\": \"%{smart_count} opgeslagen bestand |||| %{smart_count} opgeslagen bestanden\",\n    \"error\": \"Er is iets misgegaan; probeer het opnieuw.\"\n  },\n  \"FileOpenerExternal\": {\n    \"fileNotFoundError\": \"Fout: bestand niet gevonden\"\n  },\n  \"TOS\": {\n    \"updated\": {\n      \"title\": \"De GDPR is werkelijkheid geworden!\",\n      \"detail\": \"In verband met de General Data Protection Regulation, ook wel AVG, [zijn onze algemene voorwaarden bijgewerkt](%{link}) en van toepassing op alle Twake-gebruikers vanaf 25 mei 2018.\",\n      \"cta\": \"Voorwaarden accepteren en doorgaan\",\n      \"disconnect\": \"Weigeren en verbinding verbreken\",\n      \"error\": \"Er is iets misgegaan; probeer het later opnieuw.\"\n    }\n  },\n  \"manifest\": {\n    \"permissions\": {\n      \"contacts\": {\n        \"description\": \"Vereist om bestanden te kunnen delen met je contactpersonen\"\n      },\n      \"groups\": {\n        \"description\": \"Vereist om bestanden te kunnen delen in je groepen\"\n      }\n    }\n  },\n  \"models\": {\n    \"contact\": {\n      \"defaultDisplayName\": \"Anoniem\"\n    }\n  },\n  \"Scan\": {\n    \"scan_a_doc\": \"Document scannen\",\n    \"save_doc\": \"Document opslaan\",\n    \"filename\": \"Bestandsnaam\",\n    \"save\": \"Opslaan\",\n    \"cancel\": \"Annuleren\",\n    \"qualify\": \"Categoriseren\",\n    \"apply\": \"Toepassen\",\n    \"error\": {\n      \"offline\": \"Je kunt deze functie momenteel niet gebruiken omdat je offline bent. Probeer het later opnieuw.\",\n      \"uploading\": \"Je bent al een bestand aan het uploaden. Wacht tot dat is afgerond en probeer het dan opnieuw.\",\n      \"generic\": \"Er is iets misgegaan; probeer het opnieuw.\"\n    },\n    \"successful\": {\n      \"qualified_ok\": \"Je hebt je eerste bestand gecategoriseerd!\"\n    }\n  },\n  \"History\": {\n    \"description\": \"De laatste 20 versies van je bestanden worden automatisch bewaard. Selecteer een versie om deze te downloaden.\",\n    \"current_version\": \"Huidige versie\",\n    \"loading\": \"Bezig met laden…\",\n    \"noFileVersionEnabled\": \"Je Twake kan binnenkort de recenste bestandsaanpassingen archiveren zodat je nooit meer een bestand kwijtraakt\"\n  },\n  \"External\": {\n    \"redirection\": {\n      \"title\": \"Doorverwijzing\",\n      \"text\": \"Je wordt doorverwezen…\",\n      \"error\": \"Doorverwijzing mislukt. Normaliter betekent dit dat de bestandsinhoud niet goed is opgemaakt.\"\n    }\n  },\n  \"RenameModal\": {\n    \"title\": \"Naam wijzigen\",\n    \"description\": \"Je staat op het punt om de bestandsextensie te wijzigen. Wil je doorgaan?\",\n    \"continue\": \"Doorgaan\",\n    \"cancel\": \"Annuleren\"\n  },\n  \"Shortcut\": {\n    \"title_modal\": \"Snelkoppeling maken\",\n    \"filename\": \"Bestandsnaam\",\n    \"url\": \"URL\",\n    \"cancel\": \"Annuleren\",\n    \"create\": \"Maken\",\n    \"created\": \"De snelkoppeling is gemaakt\",\n    \"errored\": \"Er is een fout opgetreden\",\n    \"filename_error_ends\": \"De naam moet eindigen op .url\",\n    \"needs_info\": \"De snelkoppeling moet op zijn minst voorzien zijn van een url en bestandsnaam\",\n    \"url_badformat\": \"De url is onjuist opgemaakt\"\n  },\n  \"OnlyOffice\": {\n    \"Error\": {\n      \"title\": \"Er is iets misgegaan\",\n      \"text\": \"Herlaad de pagina\"\n    },\n    \"readOnly\": {\n      \"title\": \"Alleen-lezen\",\n      \"tooltip\": \"Je bent alleen bevoegd om dit document te lezen - neem contact op met de eigenaar als je het ook wilt kunnen bewerken.\"\n    },\n    \"createFileName\": {\n      \"text\": \"Nieuw tekstdocument\",\n      \"spreadsheet\": \"Nieuw werkblad\",\n      \"slide\": \"Nieuwe presentatie\"\n    },\n    \"toolbar\": {\n      \"goToHome\": \"Ga naar overzicht\"\n    },\n    \"actions\": {\n      \"edit\": \"Bewerken\",\n      \"validate\": \"Verifiëren\"\n    }\n  },\n  \"Migration\": {\n    \"title\": \"Twake Schijf bijwerken\",\n    \"content\": \"Twake Schijf moet worden bijgewerkt om de prestaties ervan te verbeteren - dit kan enkele minuten duren. Gedurende deze periode kun je de app niet gebruiken. Wil je Twake Schijf nu bijwerken? Als je dat niet wilt, dan vragen we het volgende keer opnieuw.\",\n    \"confirm\": \"Ja, doe maar!\",\n    \"cancel\": \"Nee, niet nu\"\n  },\n  \"searchbar\": {\n    \"placeholder\": \"Zoeken naar iets\",\n    \"empty\": \"Geen resultaten gevonden voor de zoekopdracht \\\"%{query}\\\"\"\n  },\n  \"button\": {\n    \"back\": \"Terug\",\n    \"add\": \"Toevoegen\",\n    \"create\": \"Creëren\"\n  },\n  \"search\": {\n    \"action\": \"Zoeken\",\n    \"empty\": {\n      \"title\": \"Geen zoekresultaten\",\n      \"subtitle\": \"Geen resultaten gevonden voor de zoekopdracht \\\"%{query}\\\"\"\n    }\n  },\n  \"PushBanner\": {\n    \"quota\": {\n      \"text\": \"Je hebt nog weinig opslagruimte. Als je het limiet bereikt, dan kun je geen bestanden meer toevoegen. Je kunt bestanden verwĳderen, de prullenbak legen of een ander abonnement kiezen om ruimte vrĳ te maken.\",\n      \"actions\": {\n        \"first\": \"Ik begrĳp het\",\n        \"second\": \"Abonnementen bekĳken\"\n      }\n    }\n  },\n  \"EntriesType\": {\n    \"file\": \"bestand |||| bestanden\",\n    \"directory\": \"map |||| mappen\",\n    \"element\": \"item |||| items\"\n  },\n  \"actions\": {\n    \"details\": \"Details\",\n    \"personalizeFolder\": {\n      \"label\": \"Map personaliseren\"\n    },\n    \"summariseByAI\": \"Samenvatten\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"Map personaliseren\",\n    \"description\": \"Kies een specifieke kleur voor uw map\",\n    \"cancel\": \"Annuleren\",\n    \"apply\": \"Toepassen\",\n    \"error\": \"Er is een fout opgetreden, probeer het opnieuw.\",\n    \"tabs\": {\n      \"colors\": \"Kleuren\",\n      \"icons\": \"Pictogrammen\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"Recente\",\n      \"chooseCustomIcon\": \"Kies een aangepast pictogram\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/locales/pl.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"Dysk\",\n    \"item_recent\": \"Bieżące\",\n    \"item_shared\": \"Udostępnione przeze mnie\",\n    \"item_activity\": \"Aktywności\",\n    \"item_trash\": \"Kosze\",\n    \"item_settings\": \"Ustawienia\",\n    \"btn-client-web\": \"Pobierz Twake\",\n    \"btn-client-mobile\": \"Pobierz Twake Drive na urządzenie mobilne\",\n    \"link-client\": \"https://cozy.io/en/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.drive.mobile\",\n    \"link-client-ios\": \"https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"Dysk\",\n    \"title_recent\": \"Bieżące\",\n    \"title_shared\": \"Udostępnione przeze mnie\",\n    \"title_activity\": \"Aktywności\",\n    \"title_trash\": \"Kosze\"\n  },\n  \"Toolbar\": {\n    \"more\": \"Więcej\"\n  },\n  \"toolbar\": {\n    \"item_more\": \"Więcej\",\n    \"menu_select\": \"Wybierz elementy\",\n    \"menu_download_folder\": \"Pobierz folder\",\n    \"empty_trash\": \"Opróżnij kosz\",\n    \"share\": \"Udostępnij\",\n    \"leave\": \"Opuść udostępniony folder i usuń go\",\n    \"select_all\": \"Zaznacz wszystko\",\n    \"sharings_tab_all\": \"Wszystko\",\n    \"sharings_tab_drives\": \"Dyski\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"cta\": \"Udostępnij\",\n      \"title\": \"Udostępnij\",\n      \"details\": {\n        \"title\": \"Szczegóły udostępniania\",\n        \"createdAt\": \"Utworzone %{date}\"\n      },\n      \"sharedByMe\": \"Udostępnione przeze mnie\",\n      \"sharedWithMe\": \"Udostępnione dla mnie\",\n      \"shareByLink\": {\n        \"desc\": \"Każdy posiadający ten lim może zobaczyć i pobrać Twoje pliki.\"\n      },\n      \"shareByEmail\": {\n        \"email\": \"Do:\",\n        \"send\": \"Wyślij\",\n        \"genericSuccess\": \"Wysłałeś zaproszenie do %{count} kontaktów.\",\n        \"success\": \"Wysłałeś zaproszenie do %{email}.\"\n      }\n    }\n  },\n  \"searchbar\": {\n    \"placeholder\": \"Szukaj czegokolwiek\",\n    \"empty\": \"Brak wyników dla wyszukania \\\"%{query}\\\"\"\n  },\n  \"search\": {\n    \"empty\": {\n      \"subtitle\": \"Brak wyników dla wyszukania \\\"%{query}\\\"\"\n    }\n  },\n  \"alert\": {\n    \"item_copied\": \"1 element skopiowany\",\n    \"items_copied\": \"%{count} elementów skopiowanych\",\n    \"item_cut\": \"1 element wycięty\",\n    \"items_cut\": \"%{count} elementów wyciętych\",\n    \"item_moved\": \"1 element został przeniesiony\",\n    \"items_moved\": \"%{count} elementów zostało przeniesionych\",\n    \"item_pasted\": \"1 element został przeniesiony\",\n    \"items_pasted\": \"%{count} elementów zostało przeniesionych\",\n    \"copy_files_only\": \"Nie można kopiować folderów\",\n    \"copy_not_allowed\": \"Operacja kopiowania nie jest dozwolona w tym widoku.\",\n    \"cut_not_allowed\": \"Operacja wycinania nie jest dozwolona w tym widoku.\",\n    \"paste_error\": \"Wystąpił błąd podczas wklejania plików\",\n    \"paste_failed\": \"Wklejanie plików nie powiodło się\",\n    \"paste_sharing_error\": \"Nie można wkleić plików z powodu ograniczeń udostępniania. Zamiast tego użyj akcji Przenieś.\",\n    \"paste_same_folder_skipped\": \"Nie można przenieść elementów do tego samego folderu, w którym już się znajdują.\",\n    \"paste_not_allowed\": \"Nie możesz wkleić do tego folderu\",\n    \"cannot_move_shared_drive\": \"Nie możesz przenieść folderu dysku udostępnionego\",\n    \"cannot_copy_shared_drive\": \"Nie możesz skopiować folderu dysku współdzielonego\"\n  },\n  \"actions\": {\n    \"details\": \"Szczegóły\",\n    \"personalizeFolder\": {\n      \"label\": \"Personalizuj folder\"\n    },\n    \"summariseByAI\": \"Podsumuj\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"Personalizuj folder\",\n    \"description\": \"Wybierz konkretny kolor dla swojego folderu\",\n    \"cancel\": \"Anuluj\",\n    \"apply\": \"Zastosuj\",\n    \"error\": \"Wystąpił błąd, spróbuj ponownie.\",\n    \"tabs\": {\n      \"colors\": \"Kolory\",\n      \"icons\": \"Ikony\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"Ostatnie\",\n      \"chooseCustomIcon\": \"Wybierz niestandardową ikonę\"\n    }\n  }\n}"
  },
  {
    "path": "src/locales/ru.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"Мой диск\",\n    \"item_recent\": \"Недавние\",\n    \"item_sharings\": \"Общие\",\n    \"item_shared\": \"Мои отправленные файлы\",\n    \"item_activity\": \"Активность\",\n    \"item_trash\": \"Корзина\",\n    \"item_migration\": \"Миграция\",\n    \"item_settings\": \"Настройки\",\n    \"item_collect\": \"Администрирование\",\n    \"item_shared_drives\": \"Общие диски\",\n    \"item_favorites\": \"Избранное\",\n    \"item_my_drive\": \"Мой диск\",\n    \"btn-client\": \"Получить достуа к Twake Drive для ПК\",\n    \"support-us\": \"Посмотреть предложения\",\n    \"support-us-description\": \"Хотите получить больше места или просто поддержать Cozy?\",\n    \"btn-client-web\": \"Получить доступ к Twake\",\n    \"btn-client-mobile\": \"Возьмите свой персональный облачный сервис с собой: установите %{name} на все устройства!\",\n    \"banner-txt-client\": \"Получите %{name} для ПК и безопасно синхронизируйте свои файлы, чтобы они всегда были доступны.\",\n    \"banner-btn-client\": \"Скачать\",\n    \"link-client\": \"https://cozy.io/en/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.flagship.mobile\",\n    \"link-client-ios\": \"https://apps.apple.com/app/cloud-personnel-cozy/id1600636174\",\n    \"link-client-web\": \"https://cozy.io/try-it\",\n    \"view_more\": \"Показать больше\",\n    \"view_less\": \"Показать меньше\",\n    \"item_nextcloud\": \"Nextcloud\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"Файлы\",\n    \"title_recent\": \"Недавние\",\n    \"title_sharings\": \"Общие\",\n    \"title_shared\": \"Мои отправленные файлы\",\n    \"title_activity\": \"Активность\",\n    \"title_trash\": \"Корзина\",\n    \"label\": \"Показать путь\",\n    \"title_shared_drives\": \"Диски\",\n    \"title_favorites\": \"Избранное\"\n  },\n  \"Toolbar\": {\n    \"more\": \"Ещё\"\n  },\n  \"toolbar\": {\n    \"menu_upload\": \"Загрузить файлы\",\n    \"item_more\": \"Ещё\",\n    \"menu_new_folder\": \"Папка\",\n    \"menu_select\": \"Выбрать элементы\",\n    \"menu_share_folder\": \"Поделиться папкой\",\n    \"menu_download\": \"Скачать\",\n    \"menu_sync_cozy\": \"Синхронизировать с моим Twake\",\n    \"add_to_mine\": \"Добавить в мой Twake\",\n    \"menu_download_folder\": \"Скачать папку\",\n    \"menu_download_file\": \"Скачать этот файл\",\n    \"menu_create_note\": \"Заметка\",\n    \"menu_create_shortcut\": \"Ярлык\",\n    \"share\": \"Поделиться\",\n    \"trash\": \"Удалить\",\n    \"delete_shared_drive\": \"Удалить общий диск\",\n    \"leave\": \"Покинуть доступную папку и удалить ее.\",\n    \"menu_add\": \"Добавить\",\n    \"menu_create\": \"Создать\",\n    \"menu_add_item\": \"Добавить элемент\",\n    \"menu_onlyOffice\": {\n      \"text\": \"Текстовый документ\",\n      \"spreadsheet\": \"Таблица\",\n      \"slide\": \"Презентация\"\n    },\n    \"select_all\": \"Выбрать всё\",\n    \"select_all_mobile\": \"все\",\n    \"clear_selection\": \"Очистить выбор\",\n    \"clear_selection_mobile\": \"отмена\",\n    \"sharings_tab_all\": \"Всё\",\n    \"sharings_tab_drives\": \"Диски\"\n  },\n  \"Share\": {\n    \"create-cozy\": \"Создать Twake Drive\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"cta\": \"Поделиться\",\n      \"title\": \"Поделиться\",\n      \"details\": {\n        \"title\": \"Информация об общем доступе\",\n        \"createdAt\": \"%{date}\",\n        \"ro\": \"Можно читать\",\n        \"rw\": \"Можно изменять\",\n        \"desc\": {\n          \"ro\": \"Вы можете просматривать, скачивать и добавлять эти файлы на свой диск. Вы будете получать обновления от владельца, но не сможете вносить изменения.\",\n          \"rw\": \"Вы можете просматривать, редактировать, удалять и добавлять файлы на свой диск. Ваши изменения будут видны другим пользователям.\"\n        }\n      },\n      \"shared\": \"Общий доступ\",\n      \"sharedByMe\": \"Мои отправленные файлы\",\n      \"sharedWithMe\": \"Доступно мне\",\n      \"sharedBy\": \"Доступ предоставлен %{name}\",\n      \"shareByLink\": {\n        \"subtitle\": \"По публичной ссылке\",\n        \"desc\": \"Любой, у кого есть эта ссылка, может просматривать и скачивать ваши файлы.\",\n        \"creating\": \"Создание ссылки...\",\n        \"copy\": \"Копировать ссылку\",\n        \"copied\": \"Ссылка скопирована в буфер обмена\",\n        \"failed\": \"Не удалось скопировать в буфер обмена\"\n      },\n      \"shareByEmail\": {\n        \"subtitle\": \"По email\",\n        \"email\": \"Кому:\",\n        \"emailPlaceholder\": \"Введите email или имя получателя\",\n        \"send\": \"Отправить\",\n        \"genericSuccess\": \"Вы отправили %{count} приглашений контактам.\",\n        \"success\": \"Вы отправили приглашение %{email}.\",\n        \"comingsoon\": \"Скоро вы сможете делиться документами и фотографиями в один клик с семьёй, друзьями и коллегами. Мы сообщим, когда функция будет доступна!\",\n        \"onlyByLink\": \"%{type} можно отправить только по ссылке, потому что\",\n        \"type\": {\n          \"file\": \"файл\",\n          \"folder\": \"папка\"\n        },\n        \"hasSharedParent\": \"он находится в общей папке\",\n        \"hasSharedChild\": \"он содержит общий элемент\"\n      },\n      \"revoke\": {\n        \"title\": \"Прекратить общий доступ\",\n        \"desc\": \"Этот контакт сохранит копию, но изменения больше не будут синхронизироваться.\",\n        \"success\": \"Вы прекратили общий доступ к файлу для %{email}.\"\n      },\n      \"revokeSelf\": {\n        \"title\": \"Прекратить мой доступ\",\n        \"desc\": \"Вы сохраните контент, но он больше не будет обновляться между вашими дисками.\",\n        \"success\": \"Ваш доступ к этому общему ресурсу отменён.\"\n      },\n      \"sharingLink\": {\n        \"title\": \"Ссылка для общего доступа\",\n        \"copy\": \"Копировать\",\n        \"copied\": \"Скопировано\"\n      },\n      \"whoHasAccess\": {\n        \"title\": \"1 человек имеет доступ |||| %{smart_count} человек имеют доступ\"\n      },\n      \"protectedShare\": {\n        \"title\": \"Скоро!\",\n        \"desc\": \"Делитесь любыми файлами по электронной почте с семьёй и друзьями!\"\n      },\n      \"close\": \"Закрыть\",\n      \"gettingLink\": \"Создание ссылки...\",\n      \"error\": {\n        \"generic\": \"Произошла ошибка при создании ссылки для общего доступа, попробуйте ещё раз.\",\n        \"revoke\": \"Произошла ошибка. Пожалуйста, свяжитесь с нами, чтобы мы могли решить эту проблему как можно скорее.\"\n      },\n      \"specialCase\": {\n        \"base\": \"Этот %{type} можно отправить только по ссылке, так как он\",\n        \"isInSharedFolder\": \"находится в общей папке\",\n        \"hasSharedFolder\": \"содержит общую папку\"\n      }\n    },\n    \"viewer-fallback\": \"Если файл начал загружаться, вы можете закрыть это окно.\",\n    \"dropzone\": {\n      \"teaser\": \"Перетащите файлы для загрузки в:\",\n      \"noFolderSupport\": \"Ваш браузер не поддерживает перетаскивание папок. Пожалуйста, загрузите файлы вручную.\"\n    }\n  },\n  \"table\": {\n    \"head_name\": \"Имя\",\n    \"head_update\": \"Последнее обновление\",\n    \"head_size\": \"Размер\",\n    \"head_status\": \"Общий доступ\",\n    \"head_thumbnail_size\": \"Изменить размер миниатюр\",\n    \"head_view_mode\": \"Режим просмотра\",\n    \"head_view_list\": \"Список\",\n    \"head_view_grid\": \"Плитка\",\n    \"row_update_format\": \"LLL d, yyyy\",\n    \"row_update_format_full\": \"LLLL d, yyyy\",\n    \"row_read_only\": \"Общий доступ (Только чтение)\",\n    \"row_read_write\": \"Общий доступ (Чтение и запись)\",\n    \"row_size_symbols\": {\n      \"B\": \"Б\",\n      \"KB\": \"КБ\",\n      \"MB\": \"МБ\",\n      \"GB\": \"ГБ\",\n      \"TB\": \"ТБ\",\n      \"PB\": \"ПБ\",\n      \"EB\": \"ЭБ\",\n      \"ZB\": \"ЗБ\",\n      \"YB\": \"ЙБ\"\n    },\n    \"row_sharing_shortcut_aria_label\": \"Новый ярлык общего доступа\",\n    \"load_more\": \"Загрузить ещё\",\n    \"mobile\": {\n      \"head_name_asc\": \"А-Я\",\n      \"head_name_desc\": \"Я-А\",\n      \"head_updated_at_asc\": \"Сначала старые\",\n      \"head_updated_at_desc\": \"Сначала новые\",\n      \"head_size_asc\": \"Сначала лёгкие\",\n      \"head_size_desc\": \"Сначала тяжёлые\"\n    },\n    \"tooltip\": {\n      \"carbonCopy\": {\n        \"title\": \"Копия\",\n        \"caption\": \"Указывает, является ли документ \\\"аутентичным и оригинальным\\\" согласно Twake Workplace, так как может утверждаться, что получен напрямую от стороннего сервиса без изменений.\"\n      },\n      \"electronicSafe\": {\n        \"title\": \"Электронный сейф\",\n        \"caption\": \"Указывает, защищён ли оригинальный документ вашим личным цифровым сейфом с сертификатами, которые придают ему доказательную силу и гарантируют хранение в течение 50 лет после загрузки.\"\n      }\n    }\n  },\n  \"Storage\": {\n    \"title\": \"Хранилище\",\n    \"availability\": \"Доступно %{smart_count} ГБ\",\n    \"increase\": \"Увеличить пространство\"\n  },\n  \"SelectionBar\": {\n    \"selected_count\": \"выбран 1 элемент |||| выбрано %{smart_count} элементов\",\n    \"share\": \"Поделиться\",\n    \"download\": \"Скачать\",\n    \"trash\": \"Удалить\",\n    \"destroy\": \"Удалить навсегда\",\n    \"rename\": \"Переименовать\",\n    \"restore\": \"Восстановить\",\n    \"close\": \"Закрыть\",\n    \"openWith\": \"Открыть с помощью...\",\n    \"applePreview\": \"Просмотр Apple\",\n    \"forward\": \"Переслать\",\n    \"forwardTo\": \"Переслать...\",\n    \"moveto\": \"Переместить в...\",\n    \"moveto_mobile\": \"Переместить\",\n    \"phone-download\": \"Сделать доступным офлайн\",\n    \"qualify\": \"Категоризировать\",\n    \"history\": \"История\",\n    \"more\": \"Ещё\",\n    \"openWithinNextcloud\": \"Открыть в Nextcloud\"\n  },\n  \"DeleteConfirm\": {\n    \"title\": \"Удалить %{filename}? |||| Удалить %{smart_count} %{type}?\",\n    \"trash\": \"Элемент будет перемещён в корзину. |||| Элементы будут перемещены в корзину.\",\n    \"restore\": \"Вы сможете восстановить его в любое время. |||| Вы сможете восстановить их в любое время.\",\n    \"share_accepted\": \"Общий доступ будет остановлен. Указанные ниже контакты сохранят копию, но ваши изменения больше не будут синхронизироваться:\",\n    \"share_waiting\": \"Общий доступ будет остановлен. Указанные ниже контакты больше не смогут принять приглашение или получить доступ к файлам:\",\n    \"share_both\": \"Общий доступ будет остановлен. Это означает, что контакты, сохранившие файлы на своем диске, сохранят их копию, а другие контакты больше не смогут получить доступ к общим файлам:\",\n    \"link\": \"Общий доступ по ссылке больше не будет активен\",\n    \"referenced\": \"Некоторые файлы в выборке связаны с фотоальбомом. Они будут удалены из него, если вы переместите их в корзину.\",\n    \"cancel\": \"Отмена\",\n    \"delete\": \"Удалить\"\n  },\n  \"EmptyTrashConfirm\": {\n    \"title\": \"Удалить навсегда?\",\n    \"forbidden\": \"Вы больше не сможете получить доступ к этим файлам.\",\n    \"restore\": \"Вы не сможете восстановить эти файлы, если у вас нет резервной копии.\",\n    \"cancel\": \"Отмена\",\n    \"delete\": \"Удалить всё\",\n    \"processing\": \"Корзина очищается. Это может занять некоторое время.\",\n    \"success\": \"Корзина очищена.\",\n    \"error\": \"Произошла ошибка, попробуйте ещё раз.\"\n  },\n  \"DestroyConfirm\": {\n    \"title\": \"Удалить %{filename}? |||| Удалить %{smart_count} %{type}?\",\n    \"forbidden\": \"Вы больше не сможете получить доступ к этому(-ой) %{type}. |||| Вы больше не сможете получить доступ к этим %{type}.\",\n    \"restore\": \"Вы не сможете восстановить этот(-у) %{type}, если у вас нет резервной копии. |||| Вы не сможете восстановить эти %{type}, если у вас нет резервной копии.\",\n    \"cancel\": \"Отмена\",\n    \"delete\": \"Удалить навсегда\",\n    \"success\": \"%{type} удален(-а) навсегда. |||| %{smart_count} %{type} удалены навсегда.\",\n    \"error\": \"Произошла ошибка, попробуйте ещё раз.\"\n  },\n  \"quotaalert\": {\n    \"title\": \"Ваше дисковое пространство заполнено :(\",\n    \"desc\": \"Пожалуйста, удалите файлы, очистите корзину или увеличьте дисковое пространство перед загрузкой новых файлов.\",\n    \"confirm\": \"OK\",\n    \"increase\": \"Увеличить дисковое пространство\"\n  },\n  \"loading\": {\n    \"message\": \"Загрузка\",\n    \"onlyOfficeCreateInProgress\": \"Создание файла...\"\n  },\n  \"empty\": {\n    \"title\": \"В этой папке нет файлов.\",\n    \"text\": \"Выберите файлы на вашем компьютере или перетащите их сюда.\",\n    \"mobile_text\": \"Выберите файлы на вашем устройстве.\",\n    \"trash_title\": \"У вас нет удалённых файлов.\",\n    \"trash_text\": \"Перемещайте ненужные файлы в корзину и удаляйте их навсегда, чтобы освободить место.\",\n    \"shared-drive_text\": \"Создайте и поделитесь вашим первым Диск.\"\n  },\n  \"error\": {\n    \"open_folder\": \"Произошла ошибка при открытии папки.\",\n    \"open_file\": \"Произошла ошибка при открытии файла.\",\n    \"button\": {\n      \"reload\": \"Обновить сейчас\"\n    },\n    \"download_file\": {\n      \"offline\": \"Для скачивания файла необходимо подключение к интернету\",\n      \"missing\": \"Этот файл отсутствует\"\n    }\n  },\n  \"Error\": {\n    \"public_unshared_title\": \"Извините, эта ссылка больше недоступна.\",\n    \"public_unshared_text\": \"Срок действия ссылки истёк, или владелец удалил её. Сообщите ему, что вы не смогли получить доступ\",\n    \"generic\": \"Что-то пошло не так. Подождите несколько минут и попробуйте снова.\"\n  },\n  \"alert\": {\n    \"could_not_open_file\": \"Не удалось открыть файл\",\n    \"try_again\": \"Произошла ошибка, попробуйте ещё раз через некоторое время.\",\n    \"restore_file_success\": \"Выбранные элементы успешно восстановлены.\",\n    \"trash_file_success\": \"Выбранные элементы перемещены в корзину.\",\n    \"destroy_file_success\": \"Выбранные элементы удалены навсегда.\",\n    \"folder_name\": \"Элемент %{folderName} уже существует, выберите другое имя.\",\n    \"file_name\": \"Элемент %{fileName} уже существует, выберите другое имя.\",\n    \"file_name_missing\": \"Отсутствует имя файла, выберите другое имя.\",\n    \"file_name_illegal_name\": \"Имя %{fileName} недопустимо, выберите другое имя.\",\n    \"file_name_illegal_characters\": \"Элемент %{fileName} содержит недопустимые символы: %{characters}\",\n    \"folder_generic\": \"Произошла ошибка, попробуйте ещё раз.\",\n    \"folder_abort\": \"Чтобы сохранить новую папку, необходимо указать её имя. Ваши данные не сохранены.\",\n    \"offline\": \"Эта функция недоступна офлайн.\",\n    \"preparing\": \"Подготовка файлов…\",\n    \"item_copied\": \"1 элемент скопирован\",\n    \"items_copied\": \"%{count} элементов скопированы\",\n    \"item_cut\": \"1 элемент вырезан\",\n    \"items_cut\": \"%{count} элементов вырезаны\",\n    \"item_moved\": \"1 элемент был перемещён\",\n    \"items_moved\": \"%{count} элементов было перемещено\",\n    \"item_pasted\": \"1 элемент был перемещён\",\n    \"items_pasted\": \"%{count} элементов было перемещено\",\n    \"copy_files_only\": \"Невозможно скопировать папки\",\n    \"copy_not_allowed\": \"Операция копирования не разрешена в этом представлении.\",\n    \"cut_not_allowed\": \"Операция вырезания не разрешена в этом представлении.\",\n    \"paste_error\": \"Произошла ошибка при вставке файлов\",\n    \"paste_failed\": \"Не удалось вставить файлы\",\n    \"paste_sharing_error\": \"Невозможно вставить файлы из-за ограничений общего доступа. Используйте действие Переместить вместо этого.\",\n    \"paste_same_folder_skipped\": \"Невозможно переместить элементы в ту же папку, в которой они уже находятся.\",\n    \"paste_not_allowed\": \"Вы не можете вставить в эту папку\",\n    \"cannot_move_shared_drive\": \"Вы не можете переместить папку общего диска\",\n    \"cannot_copy_shared_drive\": \"Вы не можете скопировать папку общего диска\"\n  },\n  \"upload\": {\n    \"label\": \"Загрузить\",\n    \"documentType\": {\n      \"file\": \"файл\",\n      \"directory\": \"папка\",\n      \"element\": \"элемент\"\n    },\n    \"alert\": {\n      \"success\": \"%{smart_count} %{type} успешно загружен. |||| %{smart_count} %{type} успешно загружены.\",\n      \"success_conflicts\": \"%{smart_count} %{type} загружен с %{conflictNumber} конфликтом(-ами). |||| %{smart_count} %{type} загружены с %{conflictNumber} конфликтом(-ами).\",\n      \"success_updated\": \"%{smart_count} %{type} загружен и %{updatedCount} обновлён. |||| %{smart_count} %{type} загружены и %{updatedCount} обновлены.\",\n      \"success_updated_conflicts\": \"%{smart_count} %{type} загружен, %{updatedCount} обновлён, c %{conflictCount} конфликтом(-ами). |||| %{smart_count} %{type} загружены, %{updatedCount} обновлены, с %{conflictCount} конфликтом(-ами).\",\n      \"updated\": \"%{smart_count} %{type} обновлён. |||| %{smart_count} %{type} обновлены.\",\n      \"updated_conflicts\": \"%{smart_count} %{type} обновлён с %{conflictCount} конфликтом(-ами). |||| %{smart_count} %{type} обновлены с %{conflictCount} конфликтом(-ами).\",\n      \"errors\": \"При загрузке %{type} произошли ошибки.\",\n      \"network\": \"Вы находитесь офлайн. Попробуйте снова после подключения.\",\n      \"fileTooLargeErrors\": \"Файл слишком большой. Максимальный размер файла: %{max_size_value} ГБ\"\n    }\n  },\n  \"intents\": {\n    \"alert\": {\n      \"error\": \"Не удалось автоматически загрузить файл, пожалуйста, загрузите его вручную через меню загрузки.\"\n    },\n    \"picker\": {\n      \"select\": \"Выбрать\",\n      \"cancel\": \"Отмена\",\n      \"new_folder\": \"Новая папка\",\n      \"instructions\": \"Выберите цель\"\n    }\n  },\n  \"UploadQueue\": {\n    \"header\": \"Загрузка %{smart_count} фото в Twake Drive |||| Загрузка %{smart_count} фото в Twake Drive\",\n    \"header_mobile\": \"Загружено %{done} из %{total}\",\n    \"header_done\": \"Успешно загружено %{done} из %{total}\",\n    \"success_flagship\": \"%{smart_count} файл успешно загружен. |||| %{smart_count} файла(-ов) успешно загружены.\",\n    \"close\": \"закрыть\",\n    \"item\": {\n      \"pending\": \"В ожидании\"\n    }\n  },\n  \"Viewer\": {\n    \"close\": \"Закрыть\",\n    \"noviewer\": {\n      \"download\": \"Скачать этот файл\",\n      \"openWith\": \"Открыть с помощью...\",\n      \"openInOnlyOffice\": \"Открыть в Only Office\",\n      \"cta\": {\n        \"saveTime\": \"Сэкономьте время!\",\n        \"installDesktop\": \"Установите инструмент синхронизации для вашего компьютера\",\n        \"accessFiles\": \"Получайте доступ к файлам прямо с компьютера\"\n      }\n    },\n    \"actions\": {\n      \"download\": \"Скачать\",\n      \"forward\": \"Переслать\"\n    },\n    \"loading\": {\n      \"error\": \"Не удалось загрузить файл. Проверьте подключение к интернету.\",\n      \"retry\": \"Повторить\"\n    },\n    \"error\": {\n      \"noapp\": \"На вашем устройстве нет приложения для открытия этого файла.\",\n      \"generic\": \"Произошла ошибка при открытии файла, попробуйте ещё раз.\",\n      \"noNetwork\": \"Вы находитесь офлайн.\"\n    },\n    \"panel\": {\n      \"title\": \"Полезная информация\"\n    }\n  },\n  \"Move\": {\n    \"to\": \"Переместить в:\",\n    \"action\": \"Переместить\",\n    \"cancel\": \"Отмена\",\n    \"modalTitle\": \"Переместить\",\n    \"title\": \"%{smart_count} элемент |||| %{smart_count} элементов\",\n    \"success\": \"%{subject} перемещён в %{target}. |||| %{smart_count} элементов перемещены в %{target}.\",\n    \"error\": \"Произошла ошибка при перемещении элемента, попробуйте позже. |||| Произошла ошибка при перемещении элементов, попробуйте позже.\",\n    \"cancelled\": \"%{subject} возвращён в исходное расположение. |||| %{smart_count} элементов возвращены в исходное расположение.\",\n    \"cancelledWithRestoreErrors\": \"%{subject} возвращён в исходное расположение, но произошла ошибка при восстановлении из корзины. |||| %{smart_count} элементов возвращены в исходное расположение, но произошло %{restoreErrorsCount} ошибок при восстановлении из корзины.\",\n    \"cancelled_error\": \"Извините, произошла ошибка при возврате элемента. |||| Извините, произошла ошибка при возврате элементов.\",\n    \"multipleEntries\": \"%{smart_count} элемент |||| %{smart_count} элементов\",\n    \"addFolder\": \"Добавить папку\",\n    \"outsideSharedFolder\": {\n      \"title\": \"Перемещение за пределы папки %{sharedFolder}\",\n      \"content_1\": \"Внимание: вы хотите переместить %{name} из общей папки %{sharedFolder}. |||| Внимание: вы хотите переместить %{smart_count} %{type} из общей папки %{sharedFolder}.\",\n      \"content_2\": \"Это перемещение отменит общий доступ к %{type} %{name}. Этот %{type} будет перемещён в корзину для всех участников общего доступа. |||| Это перемещение отменит общий доступ к %{smart_count} %{type}. Эти %{type} будут перемещены в корзину для всех участников общего доступа.\",\n      \"cancel\": \"Отмена\",\n      \"confirm\": \"Я понимаю\"\n    },\n    \"insideSharedFolder\": {\n      \"title\": \"Переместить в общую папку?\",\n      \"content\": \"Все, кто имеет доступ к %{destination}, также получат доступ к %{source}. |||| Все, кто имеет доступ к %{destination}, также получат доступ к выбранным %{type}.\",\n      \"cancel\": \"Отмена\",\n      \"confirm\": \"ОК\"\n    },\n    \"sharedFolderInsideAnother\": {\n      \"title\": \"Невозможно переместить\",\n      \"content_1\": \"Вы пытаетесь переместить общий элемент в общую папку. Это действие запрещено.\",\n      \"content_2\": \"Если вы всё же хотите переместить %{source} в %{destination}, отмените общий доступ:\",\n      \"cancel\": \"Отменить перемещение\",\n      \"confirm\": \"Отменить общий доступ\"\n    }\n  },\n  \"ImportToDrive\": {\n    \"title\": \"%{smart_count} элемент |||| %{smart_count} элементов\",\n    \"to\": \"Сохранить в:\",\n    \"action\": \"Сохранить\",\n    \"cancel\": \"Отмена\",\n    \"success\": \"%{smart_count} сохранённый файл |||| %{smart_count} сохранённых файла(-ов)\",\n    \"error\": \"Произошла ошибка. Попробуйте ещё раз\"\n  },\n  \"FileOpenerExternal\": {\n    \"fileNotFoundError\": \"Ошибка: файл не найден\"\n  },\n  \"TOS\": {\n    \"updated\": {\n      \"title\": \"GDPR вступает в силу!\",\n      \"detail\": \"В рамках Общего регламента по защите данных наши Условия обслуживания были обновлены и будут применяться ко всем пользователям Twake Workplace с 25 мая 2018 года.\",\n      \"cta\": \"Принять Условия и продолжить\",\n      \"disconnect\": \"Отказаться и выйти\",\n      \"error\": \"Произошла ошибка, попробуйте позже\"\n    }\n  },\n  \"manifest\": {\n    \"permissions\": {\n      \"contacts\": {\n        \"description\": \"Необходимо, чтобы делиться файлами с вашими контактами\"\n      },\n      \"groups\": {\n        \"description\": \"Необходимо, чтобы делиться файлами с вашими группами\"\n      }\n    }\n  },\n  \"models\": {\n    \"contact\": {\n      \"defaultDisplayName\": \"Анонимно\"\n    }\n  },\n  \"Scan\": {\n    \"none\": \"Ничего\",\n    \"scan_a_doc\": \"Сканировать документ\",\n    \"save_doc\": \"Сохранить документ\",\n    \"filename\": \"Имя файла\",\n    \"save\": \"Сохранить\",\n    \"cancel\": \"Отмена\",\n    \"qualify\": \"Категоризировать\",\n    \"requalify\": \"Перекатегоризировать\",\n    \"apply\": \"Применить\",\n    \"error\": {\n      \"offline\": \"Вы находитесь офлайн и не можете использовать эту функцию. Попробуйте позже.\",\n      \"uploading\": \"Вы уже загружаете файл. Дождитесь завершения загрузки и попробуйте снова.\",\n      \"generic\": \"Произошла ошибка. Попробуйте ещё раз.\"\n    },\n    \"successful\": {\n      \"qualified_ok\": \"Вы успешно категоризировали файл!\"\n    }\n  },\n  \"History\": {\n    \"description\": \"Последние 20 версий ваших файлов сохраняются автоматически. Выберите версию для скачивания.\",\n    \"current_version\": \"Текущая версия\",\n    \"loading\": \"Загрузка...\",\n    \"noFileVersionEnabled\": \"Вскоре Twake Drive сможет архивировать последние изменения файлов, чтобы вы больше не рисковали их потерять\"\n  },\n  \"External\": {\n    \"redirection\": {\n      \"title\": \"Перенаправление\",\n      \"text\": \"Вы будете перенаправлены…\",\n      \"error\": \"Ошибка при перенаправлении. Обычно это означает, что содержимое файла имеет неверный формат.\"\n    }\n  },\n  \"RenameModal\": {\n    \"title\": \"Переименовать\",\n    \"description\": \"Вы собираетесь изменить расширение файла. Продолжить?\",\n    \"continue\": \"Продолжить\",\n    \"cancel\": \"Отмена\"\n  },\n  \"Shortcut\": {\n    \"title_modal\": \"Создать ярлык\",\n    \"filename\": \"Имя файла\",\n    \"url\": \"URL\",\n    \"cancel\": \"Отмена\",\n    \"create\": \"Создать\",\n    \"created\": \"Ярлык создан\",\n    \"errored\": \"Произошла ошибка\",\n    \"filename_error_ends\": \"Имя должно заканчиваться на .url\",\n    \"needs_info\": \"Для создания ярлыка необходимы URL и имя файла\",\n    \"url_badformat\": \"URL имеет неверный формат\"\n  },\n  \"OnlyOffice\": {\n    \"Error\": {\n      \"title\": \"Что-то пошло не так\",\n      \"text\": \"Попробуйте перезагрузить страницу\"\n    },\n    \"readOnly\": {\n      \"title\": \"Только чтение\",\n      \"tooltip\": \"Вы можете только просматривать этот документ. Обратитесь к владельцу для получения прав на редактирование.\"\n    },\n    \"createFileName\": {\n      \"text\": \"Новый текстовый документ\",\n      \"spreadsheet\": \"Новая таблица\",\n      \"slide\": \"Новая презентация\"\n    },\n    \"toolbar\": {\n      \"goToHome\": \"На главную\"\n    },\n    \"actions\": {\n      \"edit\": \"Редактировать\",\n      \"validate\": \"Подтвердить\"\n    },\n    \"tooltip\": {\n      \"title\": \"Редактировать документ\",\n      \"text\": \"Документ доступен только для чтения. Вы можете изменить его, нажав здесь.\",\n      \"actions\": {\n        \"ok\": \"ОК\",\n        \"hide\": \"Не показывать\"\n      }\n    }\n  },\n  \"Migration\": {\n    \"title\": \"Обновление Twake Drive\",\n    \"content\": \"Twake Drive необходимо обновить для улучшения производительности. Это может занять несколько минут, в течение которых приложение будет недоступно. Хотите сделать это сейчас? Если вы откажетесь, мы спросим вас снова в следующий раз.\",\n    \"confirm\": \"Да, сделаем это\",\n    \"cancel\": \"Нет, не сейчас\"\n  },\n  \"searchbar\": {\n    \"placeholder\": \"Поиск\",\n    \"empty\": \"По запросу “%{query}” ничего не найдено\"\n  },\n  \"button\": {\n    \"back\": \"Назад\",\n    \"add\": \"Добавить\",\n    \"create\": \"Создать\"\n  },\n  \"search\": {\n    \"action\": \"Поиск\",\n    \"empty\": {\n      \"title\": \"Нет результатов\",\n      \"subtitle\": \"По запросу “%{query}” ничего не найдено\"\n    }\n  },\n  \"PushBanner\": {\n    \"quota\": {\n      \"text\": \"У вас почти закончилось место в хранилище. При достижении лимита вы не сможете добавлять новые файлы. Вы можете удалить файлы, очистить корзину или изменить тарифный план.\",\n      \"actions\": {\n        \"first\": \"Я понимаю\",\n        \"second\": \"Посмотреть планы\"\n      }\n    }\n  },\n  \"FileDivergedModal\": {\n    \"title\": \"Кто-то изменил этот файл\",\n    \"content\": \"Кто-то изменил файл вне Twake Drive во время вашего редактирования. Вы можете получить эти изменения вместо своих или продолжить редактирование в новом файле.\",\n    \"confirm\": \"Продолжить редактирование\",\n    \"cancel\": \"Посмотреть изменения\",\n    \"error\": \"Произошла ошибка, попробуйте ещё раз.\",\n    \"confirmReload\": {\n      \"title\": \"Посмотреть изменения\",\n      \"content\": \"При доступе к новому файлу ваши изменения будут отменены.\",\n      \"cancel\": \"Отмена\",\n      \"confirm\": \"ОК, я понял\"\n    },\n    \"viewMode\": {\n      \"title\": \"Кто-то изменил этот файл\",\n      \"content\": \"Кто-то изменил содержимое этого файла. Вы можете получить доступ к этим изменениям.\",\n      \"confirm\": \"Посмотреть изменения\"\n    }\n  },\n  \"FileDeletedModal\": {\n    \"title\": \"Кто-то удалил этот файл\",\n    \"content\": \"Кто-то удалил этот файл во время вашего редактирования. Вы можете прекратить редактирование или восстановить файл, чтобы продолжить.\",\n    \"confirm\": \"Восстановить файл\",\n    \"cancel\": \"Отменить изменения\",\n    \"error\": \"Произошла ошибка, попробуйте ещё раз.\"\n  },\n  \"TrashedBanner\": {\n    \"text\": \"Элемент находится в корзине\",\n    \"destroy\": \"Удалить навсегда\",\n    \"restore\": \"Восстановить\",\n    \"restoreSuccess\": \"Элемент восстановлен\",\n    \"restoreError\": \"Произошла ошибка, попробуйте ещё раз.\",\n    \"destroySuccess\": \"Элемент удалён\"\n  },\n  \"MigrationProgressBanner\": {\n    \"title\": \"Миграция с Nextcloud в процессе\",\n    \"percent\": \"%{percent}% завершено\",\n    \"importing\": \"Импорт %{count} файлов из Nextcloud...\",\n    \"cancel\": \"Отменить\",\n    \"done\": {\n      \"title\": \"Миграция завершена!\",\n      \"body\": \"Успешно импортировано %{count} файлов из Nextcloud\"\n    }\n  },\n  \"EntriesType\": {\n    \"file\": \"файл |||| файлы\",\n    \"directory\": \"папка |||| папки\",\n    \"element\": \"элемент |||| элементы\"\n  },\n  \"NotFound\": {\n    \"title\": \"Элемент не найден\",\n    \"text\": \"По этому адресу ничего не найдено. Возможно, есть ошибка в написании.\"\n  },\n  \"NextcloudBreadcrumb\": {\n    \"root\": \"Общие диски\",\n    \"trash\": \"Корзина\"\n  },\n  \"NextcloudToolbar\": {\n    \"share\": \"Поделиться\"\n  },\n  \"NextcloudDeleteConfirm\": {\n    \"title\": \"Удалить %{filename}? |||| Удалить %{smart_count} %{type}?\",\n    \"trash\": \"Этот элемент будет перемещён в корзину Nextcloud. |||| Эти элементы будут перемещены в корзину Nextcloud.\",\n    \"restore\": \"Вы всегда можете восстановить его из корзины Nextcloud.\",\n    \"error\": \"Произошла ошибка, попробуйте ещё раз.\",\n    \"cancel\": \"Отмена\",\n    \"delete\": \"Удалить\"\n  },\n  \"FileName\": {\n    \"sharedDrive\": \"Диски\",\n    \"trash\": \"Корзина\"\n  },\n  \"NextcloudBanner\": {\n    \"title\": \"Элементы ниже отображаются из диска NextCloud и не хранятся в вашем Twake.\"\n  },\n  \"favorites\": {\n    \"label\": {\n      \"add\": \"Добавить в избранное\",\n      \"addMobile\": \"Избранное\",\n      \"remove\": \"Удалить из избранного\"\n    },\n    \"error\": \"Произошла ошибка, попробуйте ещё раз.\",\n    \"success\": {\n      \"add\": \"%{filename} добавлен в избранное |||| Эти элементы добавлены в избранное\",\n      \"remove\": \"%{filename} удалён из избранного |||| Эти элементы удалены из избранного\"\n    }\n  },\n  \"TrashToolbar\": {\n    \"emptyTrash\": \"Очистить корзину\"\n  },\n  \"RestoreNextcloudFile\": {\n    \"label\": \"Восстановить\",\n    \"success\": \"Элемент восстановлен\",\n    \"error\": \"Произошла ошибка, попробуйте ещё раз.\"\n  },\n  \"actions\": {\n    \"details\": \"Подробности\",\n    \"infos\": \"Детали и категоризация\",\n    \"infosMobile\": \"Детали\",\n    \"duplicateTo\": {\n      \"label\": \"Дублировать в…\"\n    },\n    \"duplicateToMobile\": {\n      \"label\": \"Дублировать\"\n    },\n    \"personalizeFolder\": {\n      \"label\": \"Персонализировать папку\"\n    },\n    \"summariseByAI\": \"Резюмировать\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"Персонализировать папку\",\n    \"description\": \"Выберите определенный цвет для вашей папки\",\n    \"cancel\": \"Отмена\",\n    \"apply\": \"Применить\",\n    \"error\": \"Произошла ошибка, попробуйте ещё раз.\",\n    \"tabs\": {\n      \"colors\": \"Цвета\",\n      \"icons\": \"Иконки\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"Недавние\",\n      \"chooseCustomIcon\": \"Выберите пользовательскую иконку\"\n    }\n  },\n  \"DuplicateModal\": {\n    \"subTitle\": \"Дублировать в:\",\n    \"confirmLabel\": \"Дублировать здесь\",\n    \"success\": \"%{fileName} дублирован в %{destinationName}. |||| %{smart_count} элементов дублированы в %{destinationName}.\",\n    \"error\": \"Произошла ошибка, попробуйте ещё раз.\"\n  },\n  \"OpenFolderButton\": {\n    \"label\": \"Открыть папку\"\n  },\n  \"LastUpdate\": {\n    \"titleFormat\": \"LLLL dd, yyyy, HH:MM\"\n  },\n  \"AddMenu\": {\n    \"readOnlyFolder\": \"Это папка только для чтения. Вы не можете выполнить это действие.\"\n  },\n  \"PublicNoteRedirect\": {\n    \"error\": {\n      \"title\": \"Не удалось получить доступ к документу\",\n      \"subtitle\": \"Ссылка для общего доступа отсутствует или недействительна. Попросите владельца документа проверить доступ.\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/locales/vi.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"Ổ đĩa của tôi\",\n    \"item_recent\": \"Gần đây\",\n    \"item_sharings\": \"Chia sẻ\",\n    \"item_shared\": \"Chia sẻ bởi tôi\",\n    \"item_activity\": \"Hoạt động\",\n    \"item_trash\": \"Thùng rác\",\n    \"item_migration\": \"Di chuyển\",\n    \"item_settings\": \"Cài đặt\",\n    \"item_collect\": \"Quản trị\",\n    \"item_shared_drives\": \"Ổ đĩa dùng chung\",\n    \"item_favorites\": \"Yêu thích\",\n    \"item_my_drive\": \"Ổ đĩa của tôi\",\n    \"btn-client\": \"Tải TwakeDrive cho máy tính\",\n    \"support-us\": \"Xem những ưu đãi\",\n    \"support-us-description\": \"Bạn muốn có thêm dung lượng hay đơn giản chỉ muốn ủng hộ Cozy??\",\n    \"btn-client-web\": \"Tải Twake\",\n    \"btn-client-mobile\": \"Mang đám mây cá nhân theo bạn mọi nơi: cài đặt %{name} trên tất cả thiết bị của bạn!\",\n    \"banner-txt-client\": \"Tải %{name} cho máy tính và đồng bộ hóa tệp của bạn một cách an toàn để luôn có thể truy cập mọi lúc\",\n    \"banner-btn-client\": \"Tải xuống\",\n    \"link-client\": \"https://cozy.io/en/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.flagship.mobile\",\n    \"link-client-ios\": \"https://apps.apple.com/app/cloud-personnel-cozy/id1600636174\",\n    \"link-client-web\": \"https://cozy.io/try-it\",\n    \"view_more\": \"Hiển thị thêm\",\n    \"view_less\": \"Hiển thị ít hơn\",\n    \"item_nextcloud\": \"Nextcloud\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"Tệp\",\n    \"title_recent\": \"Gần đây\",\n    \"title_sharings\": \"Chia sẻ\",\n    \"title_shared\": \"Chia sẻ bởi tôi\",\n    \"title_activity\": \"Hoạt động\",\n    \"title_trash\": \"Thùng rác\",\n    \"label\": \"Hiển thị đường dẫ\",\n    \"title_shared_drives\": \"Ổ đĩa\",\n    \"title_favorites\": \"Yêu thích\"\n  },\n  \"Toolbar\": {\n    \"more\": \"Thêm\"\n  },\n  \"toolbar\": {\n    \"menu_upload\": \"Tải lên tệp tin\",\n    \"item_more\": \"Thêm\",\n    \"menu_new_folder\": \"Thư mục\",\n    \"menu_select\": \"Chọn mục\",\n    \"menu_share_folder\": \"Chia sẻ thư mục\",\n    \"menu_download\": \"Tải xuống\",\n    \"menu_sync_cozy\": \"Đồng bộ với Twake của tôi\",\n    \"add_to_mine\": \"Thêm vào Twake của tôi\",\n    \"menu_download_folder\": \"Tải thư mục xuống\",\n    \"menu_download_file\": \"Tải tệp này xuống\",\n    \"menu_create_note\": \"Ghi chú\",\n    \"menu_create_shortcut\": \"Lối tắt\",\n    \"share\": \"Chia sẻ\",\n    \"trash\": \"Xoá\",\n    \"leave\": \"Rời khỏi thư mục được chia sẻ & xoá\",\n    \"menu_add\": \"Thêm\",\n    \"menu_create\": \"Tạo\",\n    \"menu_add_item\": \"Thêm mục\",\n    \"menu_onlyOffice\": {\n      \"text\": \"Tài liệu văn bản\",\n      \"spreadsheet\": \"Bảng tính\",\n      \"slide\": \"Bài thuyết trình\"\n    },\n    \"select_all\": \"Chọn tất cả\",\n    \"select_all_mobile\": \"Tất cả\",\n    \"clear_selection\": \"Xóa lựa chọn\",\n    \"clear_selection_mobile\": \"Hủy\",\n    \"sharings_tab_all\": \"Tất cả\",\n    \"sharings_tab_drives\": \"Ổ đĩa\"\n  },\n  \"Share\": {\n    \"create-cozy\": \"Tạo Twake của tôi\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"cta\": \"Chia sẻ\",\n      \"title\": \"Chia sẻ\",\n      \"details\": {\n        \"title\": \"Chi tiết chia sẻ\",\n        \"createdAt\": \"On %{date}\",\n        \"ro\": \"Có thể đọc\",\n        \"rw\": \"Có quyền chỉnh sửa\",\n        \"desc\": {\n          \"ro\": \"Bạn có thể xem, tải xuống và thêm nội dung này vào Twake của mình. Bạn sẽ nhận được các bản cập nhật từ chủ sở hữu, nhưng bạn sẽ không thể tự cập nhật nội dung này.\",\n          \"rw\": \"Bạn có thể xem, cập nhật, xóa và thêm nội dung này vào Twake của mình. Những cập nhật bạn thực hiện sẽ hiển thị với các Cozy khác.\"\n        }\n      },\n      \"shared\": \"Đã chia sẻ\",\n      \"sharedByMe\": \"Chia sẻ bởi tôi\",\n      \"sharedWithMe\": \"Chia sẻ với tôi\",\n      \"sharedBy\": \"Chia sẻ bởi %{name}\",\n      \"shareByLink\": {\n        \"subtitle\": \"Bằng liên kết công khai\",\n        \"desc\": \"Bất kỳ ai có liên kết đều có thể xem và tải xuống tệp của bạn.\",\n        \"creating\": \"Đang tạo liên kết...\",\n        \"copy\": \"Sao chép liên kết\",\n        \"copied\": \"Liên kết đã được sao chép vào bộ nhớ tạm\",\n        \"failed\": \"Không thể sao chép vào bộ nhớ tạm\"\n      },\n      \"shareByEmail\": {\n        \"subtitle\": \"Bằng email\",\n        \"email\": \"Tới:\",\n        \"emailPlaceholder\": \"Nhập địa chỉ email hoặc tên người nhận\",\n        \"send\": \"Gửi\",\n        \"genericSuccess\": \"Bạn đã gửi lời mời đến %{count} liên hệ.\",\n        \"success\": \"Bạn đã gửi lời mời đến %{email}.\",\n        \"comingsoon\": \"Sắp ra mắt! Bạn sẽ có thể chia sẻ tài liệu và hình ảnh chỉ với một cú nhấp chuột với gia đình, bạn bè và cả đồng nghiệp. Đừng lo, chúng tôi sẽ thông báo khi tính năng này sẵn sàng!\",\n        \"onlyByLink\": \"Chỉ có thể chia sẻ %{type} này bằng liên kết, vì\",\n        \"type\": {\n          \"file\": \"tệp\",\n          \"folder\": \"thư mục\"\n        },\n        \"hasSharedParent\": \"nó thuộc một thư mục đã được chia sẻ\",\n        \"hasSharedChild\": \"nó chứa phần tử đã được chia sẻ\"\n      },\n      \"revoke\": {\n        \"title\": \"Gỡ chia sẻ\",\n        \"desc\": \"Liên hệ này sẽ giữ một bản sao, nhưng các thay đổi sẽ không còn được đồng bộ.\",\n        \"success\": \"Bạn đã gỡ tệp chia sẻ khỏi %{email}.\"\n      },\n      \"revokeSelf\": {\n        \"title\": \"Gỡ tôi khỏi chia sẻ\",\n        \"desc\": \"Bạn sẽ giữ lại nội dung nhưng sẽ không còn được cập nhật giữa các Twake của bạn.\",\n        \"success\": \"Bạn đã được gỡ khỏi chia sẻ này.\"\n      },\n      \"sharingLink\": {\n        \"title\": \"Liên kết chia sẻ\",\n        \"copy\": \"Sao chép\",\n        \"copied\": \"Đã sao chép\"\n      },\n      \"whoHasAccess\": {\n        \"title\": \"1 người có quyền truy cập |||| %{smart_count} người có quyền truy cập\"\n      },\n      \"protectedShare\": {\n        \"title\": \"Sắp ra mắt!\",\n        \"desc\": \"Chia sẻ mọi thứ qua email với gia đình và bạn bè!\"\n      },\n      \"close\": \"Đóng\",\n      \"gettingLink\": \"Đang khởi tạo liên kết...\",\n      \"error\": {\n        \"generic\": \"Đã xảy ra lỗi khi tạo liên kết chia sẻ tệp, vui lòng thử lại.\",\n        \"revoke\": \"Rất tiếc, đã xảy ra lỗi. Vui lòng liên hệ với chúng tôi để khắc phục sự cố này sớm nhất có thể.\"\n      },\n      \"specialCase\": {\n        \"base\": \"%{type} này chỉ có thể chia sẻ qua liên kết vì\",\n        \"isInSharedFolder\": \"nằm trong một thư mục đã được chia sẻ\",\n        \"hasSharedFolder\": \"chứa một thư mục đã được chia sẻ\"\n      }\n    },\n    \"viewer-fallback\": \"Nếu tệp đã bắt đầu tải xuống, bạn có thể đóng cửa sổ này.\",\n    \"dropzone\": {\n      \"teaser\": \"Thả tệp vào đây để tải lên:\",\n      \"noFolderSupport\": \"Trình duyệt của bạn hiện không hỗ trợ kéo & thả thư mục. Vui lòng tải tệp lên theo cách thủ công.\"\n    }\n  },\n  \"table\": {\n    \"head_name\": \"Tên\",\n    \"head_update\": \"Cập nhật lần cuối\",\n    \"head_size\": \"Kích thước\",\n    \"head_status\": \"Chia sẻ\",\n    \"head_thumbnail_size\": \"Chuyển kích thước hình thu nhỏ\",\n    \"head_view_mode\": \"Chế độ xem\",\n    \"head_view_list\": \"Chế độ danh sách\",\n    \"head_view_grid\": \"Chế độ lưới\",\n    \"row_update_format\": \"LLL d, yyyy\",\n    \"row_update_format_full\": \"LLLL d, yyyy\",\n    \"row_read_only\": \"Chia sẻ (Chỉ đọc)\",\n    \"row_read_write\": \"Chia sẻ (Đọc & Ghi)\",\n    \"row_size_symbols\": {\n      \"B\": \"B\",\n      \"KB\": \"KB\",\n      \"MB\": \"MB\",\n      \"GB\": \"GB\",\n      \"TB\": \"TB\",\n      \"PB\": \"PB\",\n      \"EB\": \"EB\",\n      \"ZB\": \"ZB\",\n      \"YB\": \"YB\"\n    },\n    \"row_sharing_shortcut_aria_label\": \"Phím tắt chia sẻ mới\",\n    \"load_more\": \"Tải thêm\",\n    \"mobile\": {\n      \"head_name_asc\": \"A-Z\",\n      \"head_name_desc\": \"Z-A\",\n      \"head_updated_at_asc\": \"Cũ nhất trước\",\n      \"head_updated_at_desc\": \"Mới nhất trước\",\n      \"head_size_asc\": \"Nhẹ nhất trước\",\n      \"head_size_desc\": \"Nặng nhất trước\"\n    },\n    \"tooltip\": {\n      \"carbonCopy\": {\n        \"title\": \"Bản sao gốc\",\n        \"caption\": \"Chỉ ra rằng tài liệu này được xác định là \\\\“xác thực và nguyên bản\\\\” bởi Twake Workplace, đơn vị lưu trữ Twake của bạn, vì nó có thể chứng minh rằng tài liệu đến trực tiếp từ dịch vụ bên thứ ba mà không bị chỉnh sửa.\"\n      },\n      \"electronicSafe\": {\n        \"title\": \"Két điện tử\",\n        \"caption\": \"Chỉ ra rằng tài liệu gốc được bảo vệ trong két điện tử cá nhân của bạn, kèm theo các chứng nhận mang lại giá trị pháp lý và đảm bảo lưu trữ trong 50 năm kể từ thời điểm nộp.\"\n      }\n    }\n  },\n  \"Storage\": {\n    \"title\": \"Dung lượng\",\n    \"availability\": \"Còn %{smart_count} GB trống\",\n    \"increase\": \"Tăng dung lượng\"\n  },\n  \"SelectionBar\": {\n    \"selected_count\": \"1 mục đã chọn |||| %{smart_count} mục đã chọn\",\n    \"share\": \"Chia sẻ\",\n    \"download\": \"tải xuống\",\n    \"trash\": \"Xoá bỏ\",\n    \"destroy\": \"Xoá vĩnh viễn\",\n    \"rename\": \"Đổi tên\",\n    \"restore\": \"Khôi phục\",\n    \"close\": \"Đóng\",\n    \"openWith\": \"Mở bằng...\",\n    \"applePreview\": \"Xem trước với Aplle\",\n    \"forward\": \"Chuyển tiếp\",\n    \"forwardTo\": \"Chuyển tiếp đến...\",\n    \"moveto\": \"Di chuyển tới…\",\n    \"moveto_mobile\": \"Di chuyển\",\n    \"phone-download\": \"Tải về để dùng ngoại tuyến\",\n    \"qualify\": \"Phân loại\",\n    \"history\": \"Lịch sử\",\n    \"more\": \"Thêm\",\n    \"openWithinNextcloud\": \"Mở bằng Nextcloud\"\n  },\n  \"DeleteConfirm\": {\n    \"title\": \"Xóa %{filename}? |||| Xóa %{smart_count} %{type}?\",\n    \"trash\": \"Tệp sẽ được chuyển vào Thùng rác. |||| Các tệp sẽ được chuyển vào Thùng rác.\",\n    \"restore\": \"Bạn có thể khôi phục bất cứ lúc nào. |||| Bạn có thể khôi phục chúng bất cứ lúc nào.\",\n    \"share_accepted\": \"Chia sẻ sẽ bị dừng. Những liên hệ sau sẽ giữ một bản sao, nhưng thay đổi của bạn sẽ không còn được đồng bộ:\",\n    \"share_waiting\": \"Chia sẻ sẽ bị dừng. Những liên hệ sau sẽ không còn có thể chấp nhận chia sẻ và truy cập nội dung nữa:\",\n    \"share_both\": \"Chia sẻ sẽ bị dừng. Một số liên hệ đã lưu tệp vào Twake sẽ giữ bản sao, còn những người khác sẽ mất quyền truy cập:\",\n    \"link\": \"Liên kết chia sẻ sẽ bị vô hiệu hóa\",\n    \"referenced\": \"Một số tệp trong lựa chọn liên kết với album ảnh. Nếu tiếp tục xóa, chúng sẽ bị loại khỏi album.\",\n    \"cancel\": \"Huỷ\",\n    \"delete\": \"Xoá\"\n  },\n  \"EmptyTrashConfirm\": {\n    \"title\": \"Xóa vĩnh viễn?\",\n    \"forbidden\": \"Bạn sẽ không thể truy cập các tệp này nữa.\",\n    \"restore\": \"Bạn sẽ không thể khôi phục nếu chưa sao lưu.\",\n    \"cancel\": \"Huỷ\",\n    \"delete\": \"Xoá tất cả\",\n    \"processing\": \"Đang dọn Thùng rác. Việc này có thể mất vài phút.\",\n    \"success\": \"Thùng rác đã được làm trống.\",\n    \"error\": \"Đã xảy ra lỗi, vui lòng thử lại.\"\n  },\n  \"DestroyConfirm\": {\n    \"title\": \"Xóa %{filename}? |||| Xóa %{smart_count} %{type}?\",\n    \"forbidden\": \"Bạn sẽ không thể truy cập %{type} này nữa. |||| Bạn sẽ không thể truy cập các %{type} này nữa.\",\n    \"restore\": \"Không thể khôi phục nếu bạn chưa sao lưu. |||| Không thể khôi phục các %{type} này nếu chưa sao lưu.\",\n    \"cancel\": \"Huỷ\",\n    \"delete\": \"Xóa vĩnh viễn\",\n    \"success\": \"%{type} đã được xóa vĩnh viễn. |||| %{smart_count} %{type} đã được xóa vĩnh viễn.\",\n    \"error\": \"Đã xảy ra lỗi, vui lòng thử lại.\"\n  },\n  \"quotaalert\": {\n    \"title\": \"Dung lượng của bạn đã đầy :(\",\n    \"desc\": \"Vui lòng xóa tệp, dọn Thùng rác hoặc nâng cấp dung lượng trước khi tải thêm tệp.\",\n    \"confirm\": \"OK\",\n    \"increase\": \"Tăng dung lượng\"\n  },\n  \"loading\": {\n    \"message\": \"Đang tải\",\n    \"onlyOfficeCreateInProgress\": \"Đang tạo tệp...\"\n  },\n  \"empty\": {\n    \"title\": \"Bạn chưa có tệp nào trong thư mục này.\",\n    \"text\": \"Chọn tệp trên máy tính của bạn hoặc kéo chúng vào đây.\",\n    \"mobile_text\": \"Chọn tệp trên thiết bị của bạn.\",\n    \"trash_title\": \"Bạn chưa có tệp nào trong Thùng rác.\",\n    \"trash_text\": \"Chuyển các tệp không cần thiết vào Thùng rác và xóa vĩnh viễn để giải phóng dung lượng.\",\n    \"shared-drive_text\": \"Tạo và chia sẻ ổ đĩa đầu tiên của bạn.\"\n  },\n  \"error\": {\n    \"open_folder\": \"Đã xảy ra lỗi khi mở thư mục.\",\n    \"open_file\": \"Đã xảy ra lỗi khi mở tệp.\",\n    \"button\": {\n      \"reload\": \"Tải lại ngay\"\n    },\n    \"download_file\": {\n      \"offline\": \"Bạn cần kết nối mạng để tải tệp này\",\n      \"missing\": \"Tệp này không tồn tại\"\n    }\n  },\n  \"Error\": {\n    \"public_unshared_title\": \"Rất tiếc, liên kết này không còn tồn tại.\",\n    \"public_unshared_text\": \"Liên kết này đã hết hạn hoặc bị người tạo xóa. Hãy liên hệ với họ nếu bạn bỏ lỡ!\",\n    \"generic\": \"Đã xảy ra lỗi. Vui lòng thử lại sau vài phút.\"\n  },\n  \"alert\": {\n    \"could_not_open_file\": \"Không thể mở tệp\",\n    \"try_again\": \"Đã xảy ra lỗi, vui lòng thử lại sau.\",\n    \"restore_file_success\": \"Các mục đã được khôi phục thành công.\",\n    \"trash_file_success\": \"Các mục đã được chuyển vào Thùng rác.\",\n    \"destroy_file_success\": \"Các mục đã được xóa vĩnh viễn.\",\n    \"folder_name\": \"Thư mục %{folderName} đã tồn tại, vui lòng chọn tên khác.\",\n    \"file_name\": \"Tệp %{fileName} đã tồn tại, vui lòng chọn tên khác.\",\n    \"file_name_missing\": \"Thiếu tên tệp, vui lòng đặt tên mới.\",\n    \"file_name_illegal_name\": \"Tên %{fileName} không hợp lệ, vui lòng chọn tên khác.\",\n    \"file_name_illegal_characters\": \"Tên %{fileName} chứa ký tự không hợp lệ: %{characters}\",\n    \"folder_generic\": \"Đã xảy ra lỗi, vui lòng thử lại\",\n    \"folder_abort\": \"Bạn cần đặt tên cho thư mục mới để lưu. Dữ liệu hiện tại chưa được lưu.\",\n    \"offline\": \"Tính năng này không khả dụng khi ngoại tuyến.\",\n    \"preparing\": \"Đang chuẩn bị tệp của bạn…\",\n    \"item_copied\": \"1 mục đã được sao chép\",\n    \"items_copied\": \"%{count} mục đã được sao chép\",\n    \"item_cut\": \"1 mục đã được cắt\",\n    \"items_cut\": \"%{count} mục đã được cắt\",\n    \"item_moved\": \"1 mục đã được di chuyển\",\n    \"items_moved\": \"%{count} mục đã được di chuyển\",\n    \"item_pasted\": \"1 mục đã được di chuyển\",\n    \"items_pasted\": \"%{count} mục đã được di chuyển\",\n    \"copy_files_only\": \"Không thể sao chép thư mục\",\n    \"copy_not_allowed\": \"Thao tác sao chép không được phép trong chế độ xem này.\",\n    \"cut_not_allowed\": \"Thao tác cắt không được phép trong chế độ xem này.\",\n    \"paste_error\": \"Đã xảy ra lỗi khi dán tệp\",\n    \"paste_failed\": \"Dán tệp thất bại\",\n    \"paste_sharing_error\": \"Không thể dán tệp do hạn chế chia sẻ. Vui lòng sử dụng hành động Di chuyển thay thế.\",\n    \"paste_same_folder_skipped\": \"Không thể di chuyển các mục vào cùng một thư mục mà chúng đã có.\",\n    \"paste_not_allowed\": \"Bạn không thể dán vào thư mục này\",\n    \"cannot_move_shared_drive\": \"Bạn không thể di chuyển thư mục ổ đĩa chia sẻ\",\n    \"cannot_copy_shared_drive\": \"Bạn không thể sao chép thư mục ổ đĩa chia sẻ\"\n  },\n  \"upload\": {\n    \"label\": \"Tải lên\",\n    \"documentType\": {\n      \"file\": \"tệp\",\n      \"directory\": \"thư mục\",\n      \"element\": \"phần tử\"\n    },\n    \"alert\": {\n      \"success\": \"Đã tải lên %{smart_count} %{type} thành công. |||| Đã tải lên %{smart_count} %{type} thành công.\",\n      \"success_conflicts\": \"Đã tải lên %{smart_count} %{type} với %{conflictNumber} xung đột. |||| Đã tải lên %{smart_count} %{type} với %{conflictNumber} xung đột.\",\n      \"success_updated\": \"Đã tải lên %{smart_count} %{type}, trong đó có %{updatedCount} được cập nhật. |||| Đã tải lên %{smart_count} %{type}, trong đó có %{updatedCount} được cập nhật.\",\n      \"success_updated_conflicts\": \"Đã tải lên %{smart_count} %{type}, %{updatedCount} được cập nhật và có %{conflictCount} xung đột. |||| Đã tải lên %{smart_count} %{type}, %{updatedCount} được cập nhật và có %{conflictCount} xung đột.\",\n      \"updated\": \"Đã cập nhật %{smart_count} %{type}. |||| Đã cập nhật %{smart_count} %{type}.\",\n      \"updated_conflicts\": \"Đã cập nhật %{smart_count} %{type} với %{conflictCount} xung đột. |||| Đã cập nhật %{smart_count} %{type} với %{conflictCount} xung đột.\",\n      \"errors\": \"Đã xảy ra lỗi trong quá trình tải lên %{type}.\",\n      \"network\": \"Bạn hiện đang ngoại tuyến. Vui lòng thử lại khi có kết nối mạng.\",\n      \"fileTooLargeErrors\": \"Tệp quá lớn. Kích thước tệp tối đa: %{max_size_value} GB\"\n    }\n  },\n  \"intents\": {\n    \"alert\": {\n      \"error\": \"Không thể tải tệp lên tự động, vui lòng tải thủ công qua menu tải lên.\"\n    },\n    \"picker\": {\n      \"select\": \"Chọn\",\n      \"cancel\": \"Huỷ\",\n      \"new_folder\": \"Thư mục mới\",\n      \"instructions\": \"Chọn thư mục đích\"\n    }\n  },\n  \"UploadQueue\": {\n    \"header\": \"Đang tải lên %{smart_count} ảnh lên Twake Drive |||| Đang tải lên %{smart_count} ảnh lên Twake Drive\",\n    \"header_mobile\": \"Đã tải lên %{done} / %{total}\",\n    \"header_done\": \"Đã tải lên thành công %{done} trong tổng số %{total}\",\n    \"success_flagship\": \"Đã tải lên %{smart_count} tệp thành công. |||| Đã tải lên %{smart_count} tệp thành công.\",\n    \"close\": \"đóng\",\n    \"item\": {\n      \"pending\": \"Đang chờ\"\n    }\n  },\n  \"Viewer\": {\n    \"close\": \"Đóng\",\n    \"noviewer\": {\n      \"download\": \"Tải tệp này xuống\",\n      \"openWith\": \"Mở bằng...\",\n      \"openInOnlyOffice\": \"Mở bằng OnlyOffice\",\n      \"cta\": {\n        \"saveTime\": \"Tiết kiệm thời gian!\",\n        \"installDesktop\": \"Cài đặt công cụ đồng bộ hóa cho máy tính của bạn\",\n        \"accessFiles\": \"Truy cập tệp trực tiếp từ máy tính\"\n      }\n    },\n    \"actions\": {\n      \"download\": \"Tải xuống\",\n      \"forward\": \"Chuyển tiếp\"\n    },\n    \"loading\": {\n      \"error\": \"Không thể tải tệp này. Vui lòng kiểm tra kết nối Internet của bạn.\",\n      \"retry\": \"Thử lại\"\n    },\n    \"error\": {\n      \"noapp\": \"Thiết bị của bạn không có ứng dụng nào có thể mở tệp này.\",\n      \"generic\": \"Đã xảy ra lỗi khi mở tệp, vui lòng thử lại.\",\n      \"noNetwork\": \"Bạn đang ở chế độ ngoại tuyến.\"\n    },\n    \"panel\": {\n      \"title\": \"Thông tin hữu ích\"\n    }\n  },\n  \"Move\": {\n    \"to\": \"Di chuyển đến:\",\n    \"action\": \"Di chuyển\",\n    \"cancel\": \"Huỷ\",\n    \"modalTitle\": \"Di chuyển\",\n    \"title\": \"%{smart_count} phần tử |||| %{smart_count} phần tử\",\n    \"success\": \"%{subject} đã được di chuyển đến %{target}. |||| %{smart_count} phần tử đã được di chuyển đến %{target}.\",\n    \"error\": \"Đã xảy ra lỗi khi di chuyển phần tử này, vui lòng thử lại sau. |||| Đã xảy ra lỗi khi di chuyển các phần tử này, vui lòng thử lại sau.\",\n    \"cancelled\": \"%{subject} đã được đưa trở lại vị trí ban đầu. |||| %{smart_count} phần tử đã được đưa trở lại vị trí ban đầu.\",\n    \"cancelledWithRestoreErrors\": \"%{subject} đã được đưa trở lại vị trí ban đầu nhưng đã xảy ra lỗi khi khôi phục tệp từ thùng rác. |||| %{smart_count} phần tử đã được đưa trở lại vị trí ban đầu nhưng có %{restoreErrorsCount} lỗi khi khôi phục các tệp từ thùng rác.\",\n    \"cancelled_error\": \"Rất tiếc, đã xảy ra lỗi khi đưa phần tử trở lại. |||| Rất tiếc, đã xảy ra lỗi khi đưa các phần tử trở lại.\",\n    \"multipleEntries\": \"%{smart_count} phần tử |||| %{smart_count} phần tử\",\n    \"addFolder\": \"Thêm thư mục\",\n    \"outsideSharedFolder\": {\n      \"title\": \"Di chuyển ra khỏi thư mục chia sẻ %{sharedFolder}\",\n      \"content_1\": \"Cảnh báo, bạn đang muốn di chuyển %{name} ra khỏi thư mục chia sẻ %{sharedFolder}. |||| Cảnh báo, bạn đang muốn di chuyển %{smart_count} %{type} ra khỏi thư mục chia sẻ %{sharedFolder}.\",\n      \"content_2\": \"Thao tác này sẽ xóa %{type} %{name} khỏi mục chia sẻ. %{type} này sẽ bị đưa vào thùng rác đối với tất cả thành viên được chia sẻ. |||| Thao tác này sẽ xóa %{smart_count} %{type} khỏi mục chia sẻ. Các %{type} này sẽ bị đưa vào thùng rác đối với tất cả thành viên được chia sẻ.\",\n      \"cancel\": \"Hủy\",\n      \"confirm\": \"Tôi hiểu\"\n    },\n    \"insideSharedFolder\": {\n      \"title\": \"Di chuyển vào thư mục chia sẻ?\",\n      \"content\": \"Tất cả thành viên có quyền truy cập %{destination} cũng sẽ có quyền truy cập %{source}. |||| Tất cả thành viên có quyền truy cập %{destination} cũng sẽ có quyền truy cập các %{type} đã chọn.\",\n      \"cancel\": \"Hủy\",\n      \"confirm\": \"Đồng ý\"\n    },\n    \"sharedFolderInsideAnother\": {\n      \"title\": \"Không thể di chuyển\",\n      \"content_1\": \"Bạn đang muốn di chuyển một phần tử được chia sẻ vào một thư mục chia sẻ khác. Loại thao tác này không được phép.\",\n      \"content_2\": \"Nếu bạn vẫn muốn di chuyển %{source} đến %{destination}, vui lòng ngừng chia sẻ:\",\n      \"cancel\": \"Hủy di chuyển\",\n      \"confirm\": \"Ngừng chia sẻ\"\n    }\n  },\n  \"ImportToDrive\": {\n    \"title\": \"%{smart_count} phần tử |||| %{smart_count} phần tử\",\n    \"to\": \"Lưu tại:\",\n    \"action\": \"Lưu\",\n    \"cancel\": \"Hủy\",\n    \"success\": \"%{smart_count} tệp đã được lưu |||| %{smart_count} tệp đã được lưu\",\n    \"error\": \"Đã xảy ra lỗi. Vui lòng thử lại\"\n  },\n  \"FileOpenerExternal\": {\n    \"fileNotFoundError\": \"Lỗi: không tìm thấy tệp\"\n  },\n  \"TOS\": {\n    \"updated\": {\n      \"title\": \"GDPR chính thức có hiệu lực!\",\n      \"detail\": \"Trong bối cảnh Quy định bảo vệ dữ liệu chung, [Điều khoản dịch vụ của chúng tôi đã được cập nhật](%{link}) và sẽ áp dụng cho tất cả người dùng Twake từ ngày 25 tháng 5 năm 2018.\",\n      \"cta\": \"Chấp nhận Điều khoản và tiếp tục\",\n      \"disconnect\": \"Từ chối và đăng xuất\",\n      \"error\": \"Đã xảy ra lỗi, vui lòng thử lại sau\"\n    }\n  },\n  \"manifest\": {\n    \"permissions\": {\n      \"contacts\": {\n        \"description\": \"Cần thiết để chia sẻ tệp với danh bạ của bạn\"\n      },\n      \"groups\": {\n        \"description\": \"Cần thiết để chia sẻ tệp với nhóm của bạn\"\n      }\n    }\n  },\n  \"models\": {\n    \"contact\": {\n      \"defaultDisplayName\": \"Ẩn danh\"\n    }\n  },\n  \"Scan\": {\n    \"none\": \"Không có gì\",\n    \"scan_a_doc\": \"Quét tài liệu\",\n    \"save_doc\": \"Lưu tài liệu\",\n    \"filename\": \"Tên tệp\",\n    \"save\": \"Lưu\",\n    \"cancel\": \"Hủy\",\n    \"qualify\": \"Phân loại\",\n    \"requalify\": \"Phân loại lại\",\n    \"apply\": \"Áp dụng\",\n    \"error\": {\n      \"offline\": \"Bạn hiện đang ngoại tuyến và không thể sử dụng chức năng này. Vui lòng thử lại sau.\",\n      \"uploading\": \"Bạn đang tải lên một tệp. Vui lòng đợi quá trình này hoàn tất rồi thử lại.\",\n      \"generic\": \"Đã xảy ra lỗi. Vui lòng thử lại.\"\n    },\n    \"successful\": {\n      \"qualified_ok\": \"Bạn đã phân loại thành công tệp của mình! \"\n    }\n  },\n  \"History\": {\n    \"description\": \"20 phiên bản gần nhất của tệp sẽ được lưu tự động. Chọn một phiên bản để tải về.\",\n    \"current_version\": \"Phiên bản hiện tại\",\n    \"loading\": \"Đang tải...\",\n    \"noFileVersionEnabled\": \"Twake của bạn sắp có thể lưu lại những thay đổi cuối cùng để bạn không bao giờ lo mất dữ liệu nữa\"\n  },\n  \"External\": {\n    \"redirection\": {\n      \"title\": \"Chuyển hướng\",\n      \"text\": \"Bạn sắp được chuyển hướng…\",\n      \"error\": \"Lỗi trong quá trình chuyển hướng. Thông thường điều này có nghĩa là nội dung của tệp không đúng định dạng.\"\n    }\n  },\n  \"RenameModal\": {\n    \"title\": \"Đổi tên\",\n    \"description\": \"Bạn sắp thay đổi phần mở rộng của tệp. Bạn có muốn tiếp tục không?\",\n    \"continue\": \"Tiếp tục\",\n    \"cancel\": \"Hủy\"\n  },\n  \"Shortcut\": {\n    \"title_modal\": \"Tạo phím tắt\",\n    \"filename\": \"Tên tệp\",\n    \"url\": \"URL\",\n    \"cancel\": \"Hủy\",\n    \"create\": \"Tạo\",\n    \"created\": \"Phím tắt của bạn đã được tạo\",\n    \"errored\": \"Đã xảy ra lỗi\",\n    \"filename_error_ends\": \"Tên tệp phải kết thúc bằng .url\",\n    \"needs_info\": \"Phím tắt cần ít nhất một URL và tên tệp\",\n    \"url_badformat\": \"URL của bạn không đúng định dạng\"\n  },\n  \"OnlyOffice\": {\n    \"Error\": {\n      \"title\": \"Đã xảy ra sự cố\",\n      \"text\": \"Vui lòng tải lại trang\"\n    },\n    \"readOnly\": {\n      \"title\": \"Chỉ đọc\",\n      \"tooltip\": \"Bạn chỉ có quyền xem tài liệu này. Hãy liên hệ với chủ sở hữu để được cấp quyền chỉnh sửa.\"\n    },\n    \"createFileName\": {\n      \"text\": \"Tài liệu văn bản mới\",\n      \"spreadsheet\": \"Bảng tính mới\",\n      \"slide\": \"Bài thuyết trình mới\"\n    },\n    \"toolbar\": {\n      \"goToHome\": \"Về trang chính\"\n    },\n    \"actions\": {\n      \"edit\": \"Chỉnh sửa\",\n      \"validate\": \"Xác nhận\"\n    },\n    \"tooltip\": {\n      \"title\": \"Chỉnh sửa tài liệu\",\n      \"text\": \"Tài liệu hiện đang ở chế độ chỉ đọc. Bạn có thể chỉnh sửa bằng cách nhấp vào đây.\",\n      \"actions\": {\n        \"ok\": \"Đồng ý\",\n        \"hide\": \"Không hiển thị nữa\"\n      }\n    }\n  },\n  \"Migration\": {\n    \"title\": \"Cập nhật Twake Drive\",\n    \"content\": \"Twake Drive cần được cập nhật để cải thiện hiệu năng. Quá trình này có thể mất vài phút và trong thời gian đó bạn sẽ không thể sử dụng ứng dụng. Bạn có muốn cập nhật ngay bây giờ không? Nếu từ chối, chúng tôi sẽ nhắc lại vào lần sau.\",\n    \"confirm\": \"Ok, tiến hành cập nhật!\",\n    \"cancel\": \"Không, không phải bây giờ\"\n  },\n  \"searchbar\": {\n    \"placeholder\": \"Tìm kiếm bất kỳ thứ gì\",\n    \"empty\": \"Không tìm thấy kết quả cho truy vấn “%{query}”\"\n  },\n  \"button\": {\n    \"back\": \"Quay lại\",\n    \"add\": \"Thêm\",\n    \"create\": \"Tạo mới\"\n  },\n  \"search\": {\n    \"action\": \"Tìm kiếm\",\n    \"empty\": {\n      \"title\": \"Không có kết quả\",\n      \"subtitle\": \"Không tìm thấy kết quả nào cho truy vấn “%{query}”\"\n    }\n  },\n  \"PushBanner\": {\n    \"quota\": {\n      \"text\": \"Bạn sắp hết dung lượng lưu trữ. Nếu vượt quá giới hạn, bạn sẽ không thể thêm tệp mới. Hãy xóa tệp, dọn thùng rác hoặc nâng cấp gói của bạn.\",\n      \"actions\": {\n        \"first\": \"Tôi hiểu\",\n        \"second\": \"Xem các gói dịch vụ\"\n      }\n    }\n  },\n  \"FileDivergedModal\": {\n    \"title\": \"Tệp đã bị chỉnh sửa bởi người khác\",\n    \"content\": \"Ai đó đã chỉnh sửa tệp bên ngoài Twake trong khi bạn đang làm việc trên đó. Bạn có thể giữ phiên bản của họ hoặc tiếp tục chỉnh sửa bản sao mới.\",\n    \"confirm\": \"Tiếp tục chỉnh sửa\",\n    \"cancel\": \"Xem thay đổi\",\n    \"error\": \"Đã xảy ra lỗi, vui lòng thử lại.\",\n    \"confirmReload\": {\n      \"title\": \"Xem thay đổi\",\n      \"content\": \"Khi mở tệp mới, các thay đổi của bạn sẽ bị hủy.\",\n      \"cancel\": \"Hủy\",\n      \"confirm\": \"Ok, tôi hiểu\"\n    },\n    \"viewMode\": {\n      \"title\": \"Tệp đã bị chỉnh sửa bởi người khác\",\n      \"content\": \"Tệp đã bị thay đổi nội dung. Bạn có thể xem những thay đổi này.\",\n      \"confirm\": \"Xem thay đổi\"\n    }\n  },\n  \"FileDeletedModal\": {\n    \"title\": \"Tệp đã bị xóa bởi người khác\",\n    \"content\": \"Tệp đã bị xóa trong khi bạn đang chỉnh sửa. Bạn có thể dừng chỉnh sửa hoặc khôi phục tệp để tiếp tục.\",\n    \"confirm\": \"Khôi phục tệp\",\n    \"cancel\": \"Hủy thay đổi\",\n    \"error\": \"Đã xảy ra lỗi, vui lòng thử lại.\"\n  },\n  \"TrashedBanner\": {\n    \"text\": \"Mục này đang nằm trong thùng rác\",\n    \"destroy\": \"Xóa vĩnh viễn\",\n    \"restore\": \"Khôi phục\",\n    \"restoreSuccess\": \"Mục đã được khôi phục\",\n    \"restoreError\": \"Đã xảy ra lỗi, vui lòng thử lại.\",\n    \"destroySuccess\": \"Mục đã được xóa vĩnh viễn\"\n  },\n  \"MigrationProgressBanner\": {\n    \"title\": \"Đang di chuyển dữ liệu từ Nextcloud\",\n    \"percent\": \"Hoàn tất %{percent}%\",\n    \"importing\": \"Đang nhập %{count} tệp từ Nextcloud...\",\n    \"cancel\": \"Hủy\",\n    \"done\": {\n      \"title\": \"Di chuyển hoàn tất !\",\n      \"body\": \"Đã nhập thành công %{count} tệp từ Nextcloud\"\n    }\n  },\n  \"EntriesType\": {\n    \"file\": \"tệp |||| các tệp\",\n    \"directory\": \"thư mục |||| các thư mục\",\n    \"element\": \"mục |||| các mục\"\n  },\n  \"NotFound\": {\n    \"title\": \"Không tìm thấy mục\",\n    \"text\": \"Không có nội dung nào tại địa chỉ này. Có thể bạn đã nhập sai liên kết.\"\n  },\n  \"NextcloudBreadcrumb\": {\n    \"root\": \"Ổ đĩa được chia sẻ\",\n    \"trash\": \"Thùng rác\"\n  },\n  \"NextcloudToolbar\": {\n    \"share\": \"Chia sẻ\"\n  },\n  \"NextcloudDeleteConfirm\": {\n    \"title\": \"Xóa %{filename}? |||| Xóa %{smart_count} %{type}?\",\n    \"trash\": \"Mục này sẽ được chuyển vào thùng rác của Nextcloud. |||| Các mục này sẽ được chuyển vào thùng rác của Nextcloud.\",\n    \"restore\": \"Bạn có thể khôi phục bất kỳ lúc nào từ Nextcloud.\",\n    \"error\": \"Đã xảy ra lỗi, vui lòng thử lại.\",\n    \"cancel\": \"Hủy\",\n    \"delete\": \"Xóa\"\n  },\n  \"FileName\": {\n    \"sharedDrive\": \"Ổ đĩa\",\n    \"trash\": \"Thùng rác\"\n  },\n  \"NextcloudBanner\": {\n    \"title\": \"Các mục bên dưới được hiển thị từ ổ đĩa NextCloud và không được lưu trữ trong Twake.\"\n  },\n  \"favorites\": {\n    \"label\": {\n      \"add\": \"Thêm vào mục yêu thích\",\n      \"addMobile\": \"Yêu thích\",\n      \"remove\": \"Xóa khỏi mục yêu thích\"\n    },\n    \"error\": \"Đã xảy ra lỗi, vui lòng thử lại.\",\n    \"success\": {\n      \"add\": \"%{filename} đã được thêm vào mục yêu thích |||| Các mục đã được thêm vào mục yêu thích\",\n      \"remove\": \"%{filename} đã được xóa khỏi mục yêu thích |||| Các mục đã được xóa khỏi mục yêu thích\"\n    }\n  },\n  \"TrashToolbar\": {\n    \"emptyTrash\": \"Dọn thùng rác\"\n  },\n  \"RestoreNextcloudFile\": {\n    \"label\": \"Khôi phục\",\n    \"success\": \"Mục đã được khôi phục\",\n    \"error\": \"Đã xảy ra lỗi, vui lòng thử lại.\"\n  },\n  \"actions\": {\n    \"details\": \"Chi tiết\",\n    \"infos\": \"Chi tiết và phân loại\",\n    \"infosMobile\": \"Chi tiết\",\n    \"duplicateTo\": {\n      \"label\": \"Nhân bản tới…\"\n    },\n    \"duplicateToMobile\": {\n      \"label\": \"Nhân bản\"\n    },\n    \"personalizeFolder\": {\n      \"label\": \"Cá nhân hóa thư mục\"\n    },\n    \"summariseByAI\": \"Tóm tắt\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"Cá nhân hóa thư mục\",\n    \"description\": \"Chọn màu cụ thể cho thư mục của bạn\",\n    \"cancel\": \"Hủy\",\n    \"apply\": \"Áp dụng\",\n    \"error\": \"Đã xảy ra lỗi, vui lòng thử lại.\",\n    \"tabs\": {\n      \"colors\": \"Màu sắc\",\n      \"icons\": \"Biểu tượng\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"Gần đây\",\n      \"chooseCustomIcon\": \"Chọn biểu tượng tùy chỉnh\"\n    }\n  },\n  \"DuplicateModal\": {\n    \"subTitle\": \"Nhân bản đến:\",\n    \"confirmLabel\": \"Nhân bản tại đây\",\n    \"success\": \"%{fileName} đã được nhân bản đến %{destinationName}. |||| %{smart_count} mục đã được nhân bản đến %{destinationName}.\",\n    \"error\": \"Đã xảy ra lỗi, vui lòng thử lại.\"\n  },\n  \"OpenFolderButton\": {\n    \"label\": \"Mở thư mục\"\n  },\n  \"LastUpdate\": {\n    \"titleFormat\": \"LLLL dd, yyyy, HH:MM\"\n  },\n  \"AddMenu\": {\n    \"readOnlyFolder\": \"Đây là thư mục chỉ đọc. Bạn không thể thực hiện hành động này.\"\n  },\n  \"PublicNoteRedirect\": {\n    \"error\": {\n      \"title\": \"Không thể truy cập tài liệu\",\n      \"subtitle\": \"Liên kết chia sẻ bị thiếu hoặc không hợp lệ. Vui lòng yêu cầu chủ sở hữu tài liệu kiểm tra quyền truy cập\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/locales/zh_CN.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"硬盘\",\n    \"item_recent\": \"最近更改\",\n    \"item_sharings\": \"分享\",\n    \"item_shared\": \"由我分享\",\n    \"item_activity\": \"活动\",\n    \"item_trash\": \"垃圾桶\",\n    \"item_collect\": \"管理\",\n    \"btn-client\": \"下载桌面版 Twake Drive\",\n    \"btn-client-web\": \"获取 Twake\",\n    \"banner-btn-client\": \"下载\",\n    \"link-client\": \"https://cozy.io/en/download/\",\n    \"link-client-desktop\": \"https://nuts.cozycloud.cc/download/channel/stable/\",\n    \"link-client-android\": \"https://play.google.com/store/apps/details?id=io.cozy.drive.mobile\",\n    \"link-client-ios\": \"https://itunes.apple.com/us/app/cozy-drive/id1224102389?mt=8\",\n    \"link-client-web\": \"https://cozy.io/try-it\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"硬盘\",\n    \"title_recent\": \"最近更改\",\n    \"title_sharings\": \"分享\",\n    \"title_shared\": \"由我分享\",\n    \"title_activity\": \"活动\",\n    \"title_trash\": \"垃圾桶\"\n  },\n  \"Toolbar\": {\n    \"more\": \"更多\"\n  },\n  \"toolbar\": {\n    \"menu_upload\": \"上传文件\",\n    \"item_more\": \"更多\",\n    \"menu_new_folder\": \"文件夹\",\n    \"menu_select\": \"选择项目\",\n    \"menu_share_folder\": \"分析文件夹\",\n    \"menu_download\": \"下载\",\n    \"menu_sync_cozy\": \"同步到我的 Twake\",\n    \"add_to_mine\": \"添加到我的 Twake\",\n    \"menu_download_folder\": \"下载文件夹\",\n    \"menu_download_file\": \"下载这个文件\",\n    \"menu_create_note\": \"便签\",\n    \"empty_trash\": \"清空垃圾桶\",\n    \"share\": \"分享\",\n    \"trash\": \"移除\",\n    \"delete_shared_drive\": \"删除共享驱动器\",\n    \"leave\": \"离开文件夹并删除\",\n    \"menu_add\": \"添加\",\n    \"menu_create\": \"创造\",\n    \"menu_onlyOffice\": {\n      \"text\": \"文本文件\",\n      \"spreadsheet\": \"电子表格\",\n      \"slide\": \"幻灯片\"\n    },\n    \"select_all\": \"全选\",\n    \"clear_selection\": \"清晰的选择\",\n    \"sharings_tab_all\": \"全部\",\n    \"sharings_tab_drives\": \"驱动器\"\n  },\n  \"Share\": {\n    \"create-cozy\": \"创建我的 Twake\"\n  },\n  \"searchbar\": {\n    \"placeholder\": \"搜索任何内容\",\n    \"empty\": \"没有任何关于“%{query}”的内容\"\n  },\n  \"search\": {\n    \"empty\": {\n      \"subtitle\": \"没有任何关于“%{query}”的内容\"\n    }\n  },\n  \"alert\": {\n    \"item_copied\": \"1个项目已复制\",\n    \"items_copied\": \"%{count}个项目已复制\",\n    \"item_cut\": \"1个项目已剪切\",\n    \"items_cut\": \"%{count}个项目已剪切\",\n    \"item_moved\": \"1个项目已移动\",\n    \"items_moved\": \"%{count}个项目已移动\",\n    \"item_pasted\": \"1个项目已移动\",\n    \"items_pasted\": \"%{count}个项目已移动\",\n    \"copy_files_only\": \"无法复制文件夹\",\n    \"copy_not_allowed\": \"此视图中不允许复制操作。\",\n    \"cut_not_allowed\": \"此视图中不允许剪切操作。\",\n    \"paste_error\": \"粘贴文件时出错\",\n    \"paste_failed\": \"粘贴文件失败\",\n    \"paste_sharing_error\": \"由于共享限制，无法粘贴文件。请改用移动操作。\",\n    \"paste_same_folder_skipped\": \"无法将项目移动到它们已经所在的同一文件夹。\",\n    \"paste_not_allowed\": \"您无法粘贴到此文件夹\",\n    \"cannot_move_shared_drive\": \"您无法移动共享驱动器文件夹\",\n    \"cannot_copy_shared_drive\": \"您无法复制共享云端硬盘文件夹\"\n  },\n  \"actions\": {\n    \"details\": \"详细信息\",\n    \"personalizeFolder\": {\n      \"label\": \"个性化文件夹\"\n    },\n    \"summariseByAI\": \"总结\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"个性化文件夹\",\n    \"description\": \"为您的文件夹选择特定颜色\",\n    \"cancel\": \"取消\",\n    \"apply\": \"应用\",\n    \"error\": \"发生错误，请重试。\",\n    \"tabs\": {\n      \"colors\": \"颜色\",\n      \"icons\": \"图标\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"最近使用\",\n      \"chooseCustomIcon\": \"选择自定义图标\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/locales/zh_TW.json",
    "content": "{\n  \"Nav\": {\n    \"item_drive\": \"硬碟\",\n    \"item_recent\": \"最近更改\",\n    \"item_sharings\": \"分享\",\n    \"item_shared\": \"由我分享\",\n    \"item_activity\": \"活動\",\n    \"item_trash\": \"垃圾桶\",\n    \"item_settings\": \"設定\",\n    \"item_collect\": \"管理\",\n    \"btn-client\": \"下載桌面版 Twake Drive\",\n    \"btn-client-web\": \"取得 Twake\",\n    \"btn-client-mobile\": \"在您的手機上下載 %{name}\",\n    \"banner-txt-client\": \"下載桌面版 %{name} 來安全地同步您的檔案並隨時存取它們\",\n    \"banner-btn-client\": \"下載\"\n  },\n  \"breadcrumb\": {\n    \"title_drive\": \"硬碟\",\n    \"title_recent\": \"最近更改\",\n    \"title_sharings\": \"分享\",\n    \"title_shared\": \"由我分享\",\n    \"title_activity\": \"活動\",\n    \"title_trash\": \"垃圾桶\"\n  },\n  \"Toolbar\": {\n    \"more\": \"更多\"\n  },\n  \"toolbar\": {\n    \"item_more\": \"更多\",\n    \"menu_select\": \"選擇項目\",\n    \"menu_download_folder\": \"下載資料夾\",\n    \"menu_download_file\": \"下載這個檔案\",\n    \"empty_trash\": \"清空垃圾桶\",\n    \"share\": \"分享\",\n    \"trash\": \"移除\",\n    \"delete_shared_drive\": \"刪除共用磁碟機\",\n    \"leave\": \"離開分享資料夾並刪除\",\n    \"select_all\": \"全選\",\n    \"sharings_tab_all\": \"全部\",\n    \"sharings_tab_drives\": \"磁碟機\"\n  },\n  \"Share\": {\n    \"create-cozy\": \"建立我的 Twake\"\n  },\n  \"Files\": {\n    \"share\": {\n      \"cta\": \"分享\",\n      \"title\": \"分享\",\n      \"details\": {\n        \"title\": \"分享的詳細資料\",\n        \"createdAt\": \"在 %{date}\",\n        \"ro\": \"可以檢視\",\n        \"rw\": \"可以修改\"\n      },\n      \"sharedByMe\": \"由我分享\",\n      \"sharedWithMe\": \"與我分享\",\n      \"sharedBy\": \"由 %{name} 分享\",\n      \"shareByLink\": {\n        \"subtitle\": \"由公開連結\",\n        \"desc\": \"任何人擁有這個連結可以檢視和下載您的檔案。\",\n        \"creating\": \"正在建立您的連結...\",\n        \"copy\": \"複製連結\",\n        \"copied\": \"連結已經複製到剪切版\",\n        \"failed\": \"無法複製到剪切版\"\n      },\n      \"shareByEmail\": {\n        \"subtitle\": \"由電郵\",\n        \"email\": \"收件人：\",\n        \"emailPlaceholder\": \"輸入收件人的電郵地址或名稱\",\n        \"send\": \"傳送\",\n        \"genericSuccess\": \"您傳送了邀請函給 %{count} 個聯絡人。\",\n        \"success\": \"您傳送了邀請函至 %{email}\",\n        \"type\": {\n          \"file\": \"檔案\",\n          \"folder\": \"資料夾\"\n        }\n      },\n      \"sharingLink\": {\n        \"copy\": \"複製\",\n        \"copied\": \"已複製\"\n      },\n      \"protectedShare\": {\n        \"title\": \"即將推出\"\n      },\n      \"close\": \"關閉\",\n      \"gettingLink\": \"正在取得您的連結...\"\n    }\n  },\n  \"table\": {\n    \"head_name\": \"名稱\",\n    \"head_update\": \"最後更新\",\n    \"head_size\": \"大小\",\n    \"row_read_only\": \"分享（唯讀模式）\",\n    \"row_read_write\": \"分享（讀寫模式）\",\n    \"load_more\": \"載入更多\",\n    \"mobile\": {\n      \"head_updated_at_asc\": \"最舊優先\",\n      \"head_updated_at_desc\": \"最新優先\",\n      \"head_size_asc\": \"最小優先\",\n      \"head_size_desc\": \"最大優先\"\n    }\n  },\n  \"Storage\": {\n    \"title\": \"儲存空間\",\n    \"availability\": \"可用 %{smart_count} GB\",\n    \"increase\": \"增加您的空間\"\n  },\n  \"SelectionBar\": {\n    \"share\": \"分享\",\n    \"download\": \"下載\",\n    \"trash\": \"移除\",\n    \"destroy\": \"永久刪除\",\n    \"rename\": \"重新命名\",\n    \"restore\": \"還原\",\n    \"close\": \"關閉\",\n    \"moveto\": \"移動到...\",\n    \"moveto_mobile\": \"移動到\",\n    \"phone-download\": \"使離線可用\"\n  },\n  \"DeleteConfirm\": {\n    \"cancel\": \"取消\",\n    \"delete\": \"移除\"\n  },\n  \"emptytrashconfirmation\": {\n    \"title\": \"永久刪除嗎？\",\n    \"cancel\": \"取消\",\n    \"delete\": \"全部刪除\"\n  },\n  \"DestroyConfirm\": {\n    \"title\": \"永久刪除嗎？\",\n    \"cancel\": \"取消\",\n    \"delete\": \"永久刪除\"\n  },\n  \"quotaalert\": {\n    \"title\": \"您的硬碟已滿 :(\",\n    \"confirm\": \"OK\"\n  },\n  \"loading\": {\n    \"message\": \"載入中\"\n  },\n  \"empty\": {\n    \"title\": \"您在此資料夾中沒有任何檔案。\",\n    \"trash_title\": \"您沒有任何已刪除的檔案。\"\n  },\n  \"error\": {\n    \"button\": {\n      \"reload\": \"現在重新整理\"\n    },\n    \"download_file\": {\n      \"offline\": \"您需要連線才能下載此檔案\",\n      \"missing\": \"此檔案已遺失\"\n    }\n  },\n  \"Error\": {\n    \"public_unshared_title\": \"抱歉，但此連結已不可用。\"\n  },\n  \"alert\": {\n    \"could_not_open_file\": \"此檔案無法開啟\",\n    \"item_copied\": \"1個項目已複製\",\n    \"items_copied\": \"%{count}個項目已複製\",\n    \"item_cut\": \"1個項目已剪下\",\n    \"items_cut\": \"%{count}個項目已剪下\",\n    \"item_moved\": \"1個項目已移動\",\n    \"items_moved\": \"%{count}個項目已移動\",\n    \"item_pasted\": \"1個項目已移動\",\n    \"items_pasted\": \"%{count}個項目已移動\",\n    \"copy_files_only\": \"無法複製資料夾\",\n    \"copy_not_allowed\": \"此檢視中不允許複製操作。\",\n    \"cut_not_allowed\": \"此檢視中不允許剪下操作。\",\n    \"paste_error\": \"貼上檔案時發生錯誤\",\n    \"paste_failed\": \"貼上檔案失敗\",\n    \"paste_sharing_error\": \"由於共享限制，無法貼上檔案。請改用移動操作。\",\n    \"paste_same_folder_skipped\": \"無法將項目移動到它們已經所在的同一資料夾。\",\n    \"paste_not_allowed\": \"您無法貼上到此資料夾\",\n    \"cannot_move_shared_drive\": \"您無法移動共享磁碟機資料夾\",\n    \"cannot_copy_shared_drive\": \"您無法複製共用雲端硬碟資料夾\"\n  },\n  \"actions\": {\n    \"details\": \"詳細資訊\",\n    \"personalizeFolder\": {\n      \"label\": \"個性化資料夾\"\n    },\n    \"summariseByAI\": \"總結\"\n  },\n  \"FolderCustomizer\": {\n    \"title\": \"個性化資料夾\",\n    \"description\": \"為您的資料夾選擇特定顏色\",\n    \"cancel\": \"取消\",\n    \"apply\": \"套用\",\n    \"error\": \"發生錯誤，請重試。\",\n    \"tabs\": {\n      \"colors\": \"顏色\",\n      \"icons\": \"圖示\"\n    },\n    \"iconPicker\": {\n      \"recents\": \"最近使用\",\n      \"chooseCustomIcon\": \"選擇自訂圖示\"\n    }\n  }\n}\n"
  },
  {
    "path": "src/models/Contact.js",
    "content": "import { getInitials, getDisplayName } from 'cozy-client/dist/models/contact'\nimport { Contact as DoctypeContact } from 'cozy-doctypes'\n\nclass Contact extends DoctypeContact {\n  static getInitials(contactOrRecipient, defaultValue = '') {\n    if (Contact.isContact(contactOrRecipient)) {\n      return getInitials(contactOrRecipient)\n    } else {\n      const s =\n        contactOrRecipient.public_name ||\n        contactOrRecipient.name ||\n        contactOrRecipient.email\n      return (s && s[0].toUpperCase()) || defaultValue\n    }\n  }\n\n  static getDisplayName(contact, defaultValue = '') {\n    if (Contact.isContact(contact)) {\n      return getDisplayName(contact)\n    } else {\n      return (\n        contact.public_name || contact.name || contact.email || defaultValue\n      )\n    }\n  }\n}\n\nexport default Contact\n"
  },
  {
    "path": "src/models/Contact.spec.js",
    "content": "import Contact from '@/models/Contact'\n\ndescribe('Contact model', () => {\n  describe('getInitials method', () => {\n    it('should return the first letter of public_name if it is an owner recipient', () => {\n      const recipient = {\n        name: 'whatever',\n        public_name: 'janedoe'\n      }\n      const result = Contact.getInitials(recipient)\n      expect(result).toEqual('J')\n    })\n\n    it('should return the first letter of name if it is a recipient', () => {\n      const recipient = {\n        name: 'janedoe'\n      }\n      const result = Contact.getInitials(recipient)\n      expect(result).toEqual('J')\n    })\n\n    it('should return the first letter of email if it is a recipient and name/public_name are not defined', () => {\n      const recipient = {\n        name: undefined,\n        public_name: undefined,\n        email: 'janedoe@example.com'\n      }\n      const result = Contact.getInitials(recipient)\n      expect(result).toEqual('J')\n    })\n\n    it('should return an empty string if name/public_name are undefined', () => {\n      const recipient = {}\n      const result = Contact.getInitials(recipient)\n      expect(result).toEqual('')\n    })\n\n    it('should use a default value if name/public_name are undefined', () => {\n      const recipient = {}\n      const result = Contact.getInitials(recipient, 'A')\n      expect(result).toEqual('A')\n    })\n\n    it('should use the original implementation if a contact is given', () => {\n      const contact = {\n        _id: '46b5d129-0296-4466-8c02-9a6a0c17c4cb',\n        _type: 'io.cozy.contacts',\n        name: {\n          givenName: 'Arya',\n          familyName: 'Stark'\n        }\n      }\n      const result = Contact.getInitials(contact)\n      expect(result).toEqual('AS')\n    })\n  })\n\n  describe('getDisplayName method', () => {\n    it('should use the original implementation if a contact is given', () => {\n      const contact = {\n        _id: '46b5d129-0296-4466-8c02-9a6a0c17c4cb',\n        _type: 'io.cozy.contacts',\n        fullname: 'Arya Stark',\n        name: {\n          givenName: 'Arya',\n          familyName: 'Stark'\n        }\n      }\n      const result = Contact.getDisplayName(contact)\n      expect(result).toEqual('Arya Stark')\n    })\n\n    it('should use public_name if available', () => {\n      const contact = {\n        email: 'arya@winterfell.westeros',\n        name: 'Arya Stark',\n        public_name: 'aryastark'\n      }\n      const result = Contact.getDisplayName(contact)\n      expect(result).toEqual('aryastark')\n    })\n\n    it('should use name if a recipient is given', () => {\n      const contact = {\n        name: 'Arya Stark'\n      }\n      const result = Contact.getDisplayName(contact)\n      expect(result).toEqual('Arya Stark')\n    })\n\n    it('should use email if a recipient is given', () => {\n      const recipient = {\n        email: 'arya.stark@winterfell.westeros'\n      }\n      const result = Contact.getDisplayName(recipient)\n      expect(result).toEqual('arya.stark@winterfell.westeros')\n    })\n\n    it('should use an empty string as default value if nothing is available', () => {\n      const recipient = {}\n      const result = Contact.getDisplayName(recipient)\n      expect(result).toEqual('')\n    })\n\n    it('should use a default value if nothing is available', () => {\n      const recipient = {}\n      const result = Contact.getDisplayName(recipient, 'Anonymous')\n      expect(result).toEqual('Anonymous')\n    })\n  })\n})\n"
  },
  {
    "path": "src/models/index.js",
    "content": "export { CozyFile } from 'cozy-doctypes'\nexport { Group } from 'cozy-doctypes'\n\nexport { default as Contact } from '@/models/Contact'\n"
  },
  {
    "path": "src/modules/actionmenu/ActionMenuWithHeader.jsx",
    "content": "import React from 'react'\n\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'\nimport ActionsMenuMobileHeader from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuMobileHeader'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport styles from '@/styles/actionmenu.styl'\n\nimport getMimeTypeIcon from '@/lib/getMimeTypeIcon'\nimport { CozyFile } from '@/models'\n\nexport const ActionMenuWithHeader = ({\n  file,\n  actions,\n  onClose,\n  anchorElRef\n}) => {\n  return (\n    <ActionsMenu\n      open\n      ref={anchorElRef}\n      onClose={onClose}\n      actions={actions}\n      docs={[file]}\n      anchorOrigin={{\n        vertical: 'bottom',\n        horizontal: 'right'\n      }}\n    >\n      <ActionsMenuMobileHeader>\n        <MenuHeaderFile file={file} />\n      </ActionsMenuMobileHeader>\n    </ActionsMenu>\n  )\n}\n\nconst MenuHeaderFile = ({ file }) => {\n  const { filename, extension } = CozyFile.splitFilename(file)\n\n  return (\n    <>\n      <ListItemIcon>\n        <Icon\n          icon={getMimeTypeIcon(isDirectory(file), file.name, file.mime)}\n          size={32}\n        />\n      </ListItemIcon>\n      <ListItemText\n        primary={\n          <>\n            <span className={styles['fil-mobileactionmenu-file-name']}>\n              {filename}\n            </span>\n            <span className={styles['fil-mobileactionmenu-file-ext']}>\n              {extension}\n            </span>\n          </>\n        }\n        primaryTypographyProps={{ variant: 'h6' }}\n      />\n    </>\n  )\n}\n"
  },
  {
    "path": "src/modules/actions/addItems.jsx",
    "content": "import React, { forwardRef, useContext } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { AddMenuContext } from '@/modules/drive/AddMenu/AddMenuProvider'\n\nconst makeComponent = (label, icon) => {\n  const Component = forwardRef((props, ref) => {\n    const addMenuCtx = useContext(AddMenuContext)\n    const { a11y } = addMenuCtx\n\n    return (\n      <ActionsMenuItem\n        {...props}\n        onClick={() => props.onClick(addMenuCtx)}\n        ref={ref}\n        {...a11y}\n      >\n        <ListItemIcon>\n          <Icon icon={icon} />\n        </ListItemIcon>\n        <ListItemText primary={label} />\n      </ActionsMenuItem>\n    )\n  })\n  Component.displayName = 'AddItems'\n\n  return Component\n}\n\nexport const addItems = ({ t, hasWriteAccess }) => {\n  const label = t('toolbar.menu_add_item')\n  const icon = PlusIcon\n\n  return {\n    name: 'addItems',\n    label,\n    icon,\n    displayCondition: () => hasWriteAccess,\n    action: (_, { isOffline, handleOfflineClick, handleToggle }) => {\n      return isOffline ? handleOfflineClick() : handleToggle()\n    },\n    Component: makeComponent(label, icon)\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/components/addToFavorites.tsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport { splitFilename } from 'cozy-client/dist/models/file'\nimport CozyClient from 'cozy-client/types/CozyClient'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport StarOutlineIcon from 'cozy-ui/transpiled/react/Icons/StarOutline'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport type { ActionWithPolicy } from '../types'\n\ninterface addToFavoritesProps {\n  t: (key: string, options?: Record<string, unknown>) => string\n  client: CozyClient\n  isMobile: boolean\n  showAlert: import('cozy-ui/transpiled/react/providers/Alert').showAlertFunction\n}\n\nconst addToFavorites = ({\n  t,\n  client,\n  isMobile,\n  showAlert\n}: addToFavoritesProps): ActionWithPolicy => {\n  const icon = StarOutlineIcon\n  const label = isMobile\n    ? t('favorites.label.addMobile')\n    : t('favorites.label.add')\n\n  return {\n    name: 'addToFavourites',\n    label,\n    icon,\n    allowInfectedFiles: false,\n    displayCondition: docs =>\n      docs.length > 0 &&\n      docs.every(doc => !doc.cozyMetadata?.favorite) &&\n      !docs[0]?.driveId,\n    action: async (files): Promise<void> => {\n      try {\n        for (const file of files) {\n          await client.save({\n            ...file,\n            cozyMetadata: {\n              ...file.cozyMetadata,\n              favorite: true\n            }\n          })\n        }\n\n        const { filename } = splitFilename(files[0])\n        showAlert({\n          message: t('favorites.success.add', {\n            filename,\n            smart_count: files.length\n          }),\n          severity: 'success'\n        })\n      } catch (_error) {\n        showAlert({ message: t('favorites.error'), severity: 'error' })\n      }\n    },\n    Component: forwardRef(function AddToFavorites(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { addToFavorites }\n"
  },
  {
    "path": "src/modules/actions/components/duplicateTo.tsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport { isFile } from 'cozy-client/dist/models/file'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport MultiFilesIcon from 'cozy-ui/transpiled/react/Icons/MultiFiles'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { navigateToModalWithMultipleFile } from '../helpers'\nimport type { ActionWithPolicy } from '../types'\n\ninterface duplicateToProps {\n  t: (key: string, options?: Record<string, unknown>) => string\n  navigate: (to: string) => void\n  pathname: string\n  isMobile: boolean\n  search?: string\n  canDuplicate?: boolean\n}\n\nconst duplicateTo = ({\n  t,\n  pathname,\n  navigate,\n  isMobile,\n  search,\n  canDuplicate = true\n}: duplicateToProps): ActionWithPolicy => {\n  const icon = MultiFilesIcon\n  const label = isMobile\n    ? t('actions.duplicateToMobile.label')\n    : t('actions.duplicateTo.label')\n\n  return {\n    name: 'duplicateTo',\n    label,\n    icon,\n    allowInfectedFiles: false,\n    displayCondition: docs =>\n      docs.length === 1 && isFile(docs[0]) && canDuplicate,\n    action: (files): void => {\n      navigateToModalWithMultipleFile({\n        files,\n        pathname,\n        navigate,\n        path: 'duplicate',\n        search\n      })\n    },\n    Component: forwardRef(function DuplicateTo(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { duplicateTo }\n"
  },
  {
    "path": "src/modules/actions/components/moveTo.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport MovetoIcon from 'cozy-ui/transpiled/react/Icons/Moveto'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { navigateToModalWithMultipleFile } from '@/modules/actions/helpers'\nimport { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'\n\nconst moveTo = ({\n  t,\n  canMove,\n  pathname,\n  navigate,\n  isMobile,\n  search,\n  shouldHideIfSharedDriveRecipient\n}) => {\n  const icon = MovetoIcon\n  const label = isMobile\n    ? t('SelectionBar.moveto_mobile')\n    : t('SelectionBar.moveto')\n\n  return {\n    name: 'moveTo',\n    label,\n    icon,\n    allowInfectedFiles: false,\n    displayCondition: docs => {\n      // special case for rename in sharings tab\n      const isAllowedForSharedDrive = shouldHideIfSharedDriveRecipient\n        ? docs.every(doc => !isFromSharedDriveRecipient(doc))\n        : true\n      return docs.length > 0 && canMove && isAllowedForSharedDrive\n    },\n    action: async files => {\n      navigateToModalWithMultipleFile({\n        files,\n        pathname,\n        navigate,\n        path: 'move',\n        search\n      })\n    },\n    Component: forwardRef(function MoveTo(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { moveTo }\n"
  },
  {
    "path": "src/modules/actions/components/personalizeFolder.js",
    "content": "import React from 'react'\n\nimport { makeAction } from 'cozy-ui/transpiled/react/ActionsMenu/Actions/makeAction'\nimport PaletteIcon from 'cozy-ui/transpiled/react/Icons/Palette'\n\nimport { FolderCustomizerModal } from '../../views/Folder/FolderCustomizer'\n\nconst personalizeFolder = ({\n  t,\n  pushModal,\n  popModal,\n  driveId,\n  hasWriteAccess,\n  onClose\n}) => {\n  const icon = PaletteIcon\n  const label = t('actions.personalizeFolder.label')\n\n  return makeAction({\n    name: 'personalizeFolder',\n    label,\n    icon,\n    displayCondition: docs =>\n      hasWriteAccess &&\n      docs.length === 1 &&\n      docs[0].type === 'directory' &&\n      !driveId,\n    action: docs => {\n      if (docs.length === 1 && docs[0].type === 'directory') {\n        const folderId = docs[0]._id\n\n        pushModal(\n          <FolderCustomizerModal\n            folderId={folderId}\n            driveId={driveId}\n            onClose={() => {\n              popModal()\n              onClose?.()\n            }}\n          />\n        )\n      }\n    }\n  })\n}\n\nexport { personalizeFolder }\n"
  },
  {
    "path": "src/modules/actions/components/removeFromFavorites.tsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport { splitFilename } from 'cozy-client/dist/models/file'\nimport CozyClient from 'cozy-client/types/CozyClient'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport StarIcon from 'cozy-ui/transpiled/react/Icons/Star'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport type { ActionWithPolicy } from '../types'\n\ninterface removeFromFavoritesProps {\n  t: (key: string, options?: Record<string, unknown>) => string\n  client: CozyClient\n  showAlert: import('cozy-ui/transpiled/react/providers/Alert').showAlertFunction\n}\n\nconst removeFromFavorites = ({\n  t,\n  client,\n  showAlert\n}: removeFromFavoritesProps): ActionWithPolicy => {\n  const label = t('favorites.label.remove')\n  const icon = StarIcon\n\n  return {\n    name: 'removeFromFavorites',\n    label,\n    icon,\n    allowInfectedFiles: false,\n    displayCondition: docs =>\n      docs.length > 0 &&\n      docs.every(doc => doc.cozyMetadata?.favorite) &&\n      !docs[0]?.driveId,\n    action: async (files): Promise<void> => {\n      try {\n        for (const file of files) {\n          await client.save({\n            ...file,\n            cozyMetadata: {\n              ...file.cozyMetadata,\n              favorite: false\n            }\n          })\n        }\n\n        const { filename } = splitFilename(files[0])\n        showAlert({\n          message: t('favorites.success.remove', {\n            filename,\n            smart_count: files.length\n          }),\n          severity: 'success'\n        })\n      } catch (_error) {\n        showAlert({ message: t('favorites.error'), severity: 'error' })\n      }\n    },\n    Component: forwardRef(function RemoveFromFavorites(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { removeFromFavorites }\n"
  },
  {
    "path": "src/modules/actions/components/selectable.tsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport { Action } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport CheckSquareIcon from 'cozy-ui/transpiled/react/Icons/CheckSquare'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\ninterface selectableProps {\n  t: (key: string, options?: Record<string, unknown>) => string\n  showSelectionBar: () => void\n}\n\nexport const selectable = ({\n  t,\n  showSelectionBar\n}: selectableProps): Action => {\n  const label = t('toolbar.menu_select')\n  const icon = CheckSquareIcon\n\n  return {\n    name: 'selectable',\n    label,\n    icon,\n    action: (): void => {\n      showSelectionBar()\n    },\n    Component: forwardRef(function Selectable(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/details.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport flag from 'cozy-flags'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport InfoOutlinedIcon from 'cozy-ui/transpiled/react/Icons/InfoOutlined'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nconst makeComponent = (label, icon) => {\n  const Component = forwardRef((props, ref) => {\n    return (\n      <ActionsMenuItem {...props} ref={ref}>\n        <ListItemIcon>\n          <Icon icon={icon} />\n        </ListItemIcon>\n        <ListItemText primary={label} />\n      </ActionsMenuItem>\n    )\n  })\n\n  Component.displayName = 'details'\n\n  return Component\n}\n\nexport const details = ({ t, navigate, location }) => {\n  const icon = InfoOutlinedIcon\n  const label = t('actions.details')\n\n  return {\n    name: 'details',\n    icon,\n    label,\n    allowInfectedFiles: false,\n    displayCondition: () => flag('drive.new-file-viewer-ui.enabled'),\n    Component: makeComponent(label, icon),\n    action: () => {\n      navigate(location.pathname, {\n        replace: true,\n        state: {\n          ...location.state,\n          triggerDetailPanelTime:\n            (location?.state?.triggerDetailPanelTime || 0) + 1\n        }\n      })\n    }\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/divider.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport Divider from 'cozy-ui/transpiled/react/Divider'\n\nexport const hr = () => {\n  return {\n    name: 'hr',\n    icon: 'hr',\n    displayInSelectionBar: false,\n    Component: forwardRef(function hr(_, ref) {\n      return <Divider ref={ref} className=\"u-mv-half\" />\n    })\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/download.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { downloadFiles } from './utils'\n\nimport { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'\n\nconst makeComponent = (label, icon) => {\n  const Component = forwardRef((props, ref) => {\n    return (\n      <ActionsMenuItem {...props} ref={ref}>\n        <ListItemIcon>\n          <Icon icon={icon} />\n        </ListItemIcon>\n        <ListItemText primary={label} />\n      </ActionsMenuItem>\n    )\n  })\n  Component.displayName = 'Download'\n\n  return Component\n}\n\nexport const download = ({\n  client,\n  t,\n  showAlert,\n  shouldHideIfSharedDriveRecipient,\n  isSelectAll,\n  displayedFolder\n}) => {\n  const label = t('SelectionBar.download')\n  const icon = DownloadIcon\n\n  return {\n    name: 'download',\n    label,\n    icon,\n    allowInfectedFiles: false,\n    displayCondition: files => {\n      // For sharing tab where we can see multiple shared folders as\n      // recipient, disable download because we cannot download different\n      // shared folders at the same time.\n      if (\n        shouldHideIfSharedDriveRecipient &&\n        files.length > 1 &&\n        files.some(file => isFromSharedDriveRecipient(file))\n      ) {\n        return false\n      }\n      return files.length > 0\n    },\n    action: files => {\n      let selectedFiles = files\n      if (isSelectAll) {\n        selectedFiles = [displayedFolder]\n      }\n      return downloadFiles(client, selectedFiles, { showAlert, t })\n    },\n    Component: makeComponent(label, icon)\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/helpers.js",
    "content": "import { joinPath } from '@/lib/path'\n\nexport const navigateToModal = ({ navigate, pathname, files, path }) => {\n  const file = Array.isArray(files) ? files[0] : files\n\n  return navigate(\n    pathname ? joinPath(pathname, `file/${file.id}/${path}`) : `v/${path}`\n  )\n}\n\nexport const navigateToModalWithMultipleFile = ({\n  navigate,\n  pathname,\n  files,\n  path,\n  search\n}) => {\n  return navigate(\n    {\n      pathname: pathname ? joinPath(pathname, path) : `v/${path}`,\n      search: search ? `?${search}` : ''\n    },\n    {\n      state: { fileIds: files.map(file => file.id) }\n    }\n  )\n}\n\n/**\n * Returns the context menu visible actions\n *\n * @param {Object[]} actions - the list of actions\n * @returns {Object[]} - the list of actions to be displayed\n */\nexport const getContextMenuActions = (actions = []) =>\n  actions.filter(\n    action => Object.values(action)[0]?.displayInContextMenu !== false\n  )\n"
  },
  {
    "path": "src/modules/actions/helpers.spec.js",
    "content": "import {\n  navigateToModal,\n  navigateToModalWithMultipleFile,\n  getContextMenuActions\n} from './helpers'\n\njest.mock('@/lib/path', () => ({\n  joinPath: jest.fn((...paths) => paths.join('/'))\n}))\n\ndescribe('actions helpers', () => {\n  describe('navigateToModal', () => {\n    let mockNavigate\n\n    beforeEach(() => {\n      mockNavigate = jest.fn()\n    })\n\n    afterEach(() => {\n      jest.clearAllMocks()\n    })\n\n    it('should navigate to modal with pathname and single file', () => {\n      const params = {\n        navigate: mockNavigate,\n        pathname: '/folder/123',\n        files: { id: 'file-123', name: 'test.pdf' },\n        path: 'preview'\n      }\n\n      navigateToModal(params)\n\n      expect(mockNavigate).toHaveBeenCalledWith(\n        '/folder/123/file/file-123/preview'\n      )\n    })\n\n    it('should navigate to modal with pathname and array of files', () => {\n      const params = {\n        navigate: mockNavigate,\n        pathname: '/folder/456',\n        files: [\n          { id: 'file-1', name: 'first.pdf' },\n          { id: 'file-2', name: 'second.pdf' }\n        ],\n        path: 'edit'\n      }\n\n      navigateToModal(params)\n\n      expect(mockNavigate).toHaveBeenCalledWith('/folder/456/file/file-1/edit')\n    })\n  })\n\n  describe('navigateToModalWithMultipleFile', () => {\n    let mockNavigate\n\n    beforeEach(() => {\n      mockNavigate = jest.fn()\n    })\n\n    afterEach(() => {\n      jest.clearAllMocks()\n    })\n\n    it('should navigate with pathname, multiple files, and search params', () => {\n      const params = {\n        navigate: mockNavigate,\n        pathname: '/folder/123',\n        files: [\n          { id: 'file-1', name: 'doc1.pdf' },\n          { id: 'file-2', name: 'doc2.pdf' },\n          { id: 'file-3', name: 'doc3.pdf' }\n        ],\n        path: 'share',\n        search: 'tab=link'\n      }\n\n      navigateToModalWithMultipleFile(params)\n\n      expect(mockNavigate).toHaveBeenCalledWith(\n        {\n          pathname: '/folder/123/share',\n          search: '?tab=link'\n        },\n        {\n          state: { fileIds: ['file-1', 'file-2', 'file-3'] }\n        }\n      )\n    })\n\n    it('should navigate with pathname and multiple files without search params', () => {\n      const params = {\n        navigate: mockNavigate,\n        pathname: '/recent',\n        files: [\n          { id: 'file-a', name: 'image1.jpg' },\n          { id: 'file-b', name: 'image2.jpg' }\n        ],\n        path: 'move'\n      }\n\n      navigateToModalWithMultipleFile(params)\n\n      expect(mockNavigate).toHaveBeenCalledWith(\n        {\n          pathname: '/recent/move',\n          search: ''\n        },\n        {\n          state: { fileIds: ['file-a', 'file-b'] }\n        }\n      )\n    })\n\n    it('should handle empty search parameter', () => {\n      const params = {\n        navigate: mockNavigate,\n        pathname: '/folder/456',\n        files: [\n          { id: 'file-1', name: 'test1.pdf' },\n          { id: 'file-2', name: 'test2.pdf' }\n        ],\n        path: 'delete',\n        search: ''\n      }\n\n      navigateToModalWithMultipleFile(params)\n\n      expect(mockNavigate).toHaveBeenCalledWith(\n        {\n          pathname: '/folder/456/delete',\n          search: ''\n        },\n        {\n          state: { fileIds: ['file-1', 'file-2'] }\n        }\n      )\n    })\n  })\n\n  describe('getContextMenuActions', () => {\n    it('should return all actions when all have displayInContextMenu !== false', () => {\n      const actions = [\n        { download: { displayInContextMenu: true, name: 'Download' } },\n        { share: { name: 'Share' } }, // undefined displayInContextMenu should be included\n        { rename: { displayInContextMenu: undefined, name: 'Rename' } }\n      ]\n\n      const result = getContextMenuActions(actions)\n\n      expect(result).toEqual(actions)\n      expect(result).toHaveLength(3)\n    })\n\n    it('should filter out actions with displayInContextMenu: false', () => {\n      const actions = [\n        { download: { displayInContextMenu: true, name: 'Download' } },\n        { share: { displayInContextMenu: false, name: 'Share' } },\n        { rename: { name: 'Rename' } },\n        { delete: { displayInContextMenu: false, name: 'Delete' } }\n      ]\n\n      const result = getContextMenuActions(actions)\n\n      expect(result).toEqual([\n        { download: { displayInContextMenu: true, name: 'Download' } },\n        { rename: { name: 'Rename' } }\n      ])\n      expect(result).toHaveLength(2)\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/actions/index.js",
    "content": "export { share } from './share'\nexport { download } from './download'\nexport { hr } from './divider'\nexport { trash } from './trash'\nexport { rename } from './rename'\nexport { qualify } from './qualify'\nexport { versions } from './versions'\nexport { restore } from './restore'\nexport { select } from './select'\nexport { infos } from './infos'\nexport { addItems } from './addItems'\nexport { selectAllItems } from './selectAll'\nexport { summariseByAI } from './summariseByAI'\nexport { filterActionsByPolicy, hasAnyInfectedFile } from './policies'\n"
  },
  {
    "path": "src/modules/actions/index.spec.js",
    "content": "import { download } from './index'\n\ndescribe('download', () => {\n  it('should display for a single file', () => {\n    const files = [{ type: 'file' }]\n    const dl = download({ client: {}, t: () => {} })\n    expect(dl.displayCondition(files)).toBe(true)\n  })\n\n  it('should display for a folder', () => {\n    const files = [{ type: 'directory' }]\n    const dl = download({ client: {}, t: () => {} })\n    expect(dl.displayCondition(files)).toBe(true)\n  })\n\n  it('should display for a mixed selection', () => {\n    const files = [{ type: 'file' }, { type: 'directory' }]\n    const dl = download({ client: {}, t: () => {} })\n    expect(dl.displayCondition(files)).toBe(true)\n  })\n\n  it('should not display for an empty selection', () => {\n    const dl = download({ client: {}, t: () => {} })\n    expect(dl.displayCondition([])).toBe(false)\n  })\n})\n"
  },
  {
    "path": "src/modules/actions/infos.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport { isFile } from 'cozy-client/dist/models/file'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport InfoIcon from 'cozy-ui/transpiled/react/Icons/Info'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nconst makeComponent = (label, icon) => {\n  const Component = forwardRef((props, ref) => {\n    return (\n      <ActionsMenuItem {...props} ref={ref}>\n        <ListItemIcon>\n          <Icon icon={icon} />\n        </ListItemIcon>\n        <ListItemText primary={label} />\n      </ActionsMenuItem>\n    )\n  })\n\n  Component.displayName = 'infos'\n\n  return Component\n}\n\nexport const infos = ({ t, isMobile, navigate }) => {\n  const icon = InfoIcon\n  const label = isMobile ? t('actions.infosMobile') : t('actions.infos')\n\n  return {\n    name: 'infos',\n    icon,\n    label,\n    displayCondition: docs => docs.length <= 1 && isFile(docs[0]),\n    Component: makeComponent(label, icon),\n    action: docs => {\n      navigate(`file/${docs[0]._id}`)\n    }\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/policies.spec.ts",
    "content": "import type { IOCozyFile } from 'cozy-client/types/types'\n\nimport {\n  filterActionsByPolicy,\n  hasAnyInfectedFile,\n  buildPolicyContext,\n  ACTION_POLICIES\n} from './policies'\nimport type { DriveAction, ActionPolicyContext } from './types'\n\n// Mock cozy-client isDirectory\njest.mock('cozy-client/dist/models/file', () => ({\n  isDirectory: jest.fn((file: { type?: string }) => file.type === 'directory')\n}))\n\ndescribe('policies', () => {\n  // Helper to create a wrapped action (as returned by makeActions)\n  const createWrappedAction = (\n    name: string,\n    options: Partial<DriveAction> = {}\n  ): Record<string, DriveAction> => ({\n    [name]: {\n      name,\n      ...options\n    }\n  })\n\n  // Helper to create a mock file\n  const createMockFile = (\n    id: string,\n    options: {\n      infected?: boolean\n      trashed?: boolean\n      type?: 'file' | 'directory'\n      pending?: boolean\n    } = {}\n  ): Partial<IOCozyFile> => ({\n    _id: id,\n    type: options.type ?? 'file',\n    trashed: options.trashed ?? false,\n    ...(options.infected && { antivirus_scan: { status: 'infected' } }),\n    ...(options.pending && { antivirus_scan: { status: 'pending' } })\n  })\n\n  describe('buildPolicyContext', () => {\n    it('should detect infected files', () => {\n      const files = [\n        createMockFile('file1', { infected: true }),\n        createMockFile('file2')\n      ] as IOCozyFile[]\n\n      const ctx = buildPolicyContext(files)\n\n      expect(ctx.hasInfectedFile).toBe(true)\n    })\n\n    it('should detect multiple files', () => {\n      const files = [\n        createMockFile('file1'),\n        createMockFile('file2')\n      ] as IOCozyFile[]\n\n      const ctx = buildPolicyContext(files)\n\n      expect(ctx.hasMultipleFiles).toBe(true)\n    })\n\n    it('should detect folders', () => {\n      const files = [\n        createMockFile('folder1', { type: 'directory' })\n      ] as IOCozyFile[]\n\n      const ctx = buildPolicyContext(files)\n\n      expect(ctx.hasFolder).toBe(true)\n    })\n\n    it('should detect all trashed files', () => {\n      const files = [\n        createMockFile('file1', { trashed: true }),\n        createMockFile('file2', { trashed: true })\n      ] as IOCozyFile[]\n\n      const ctx = buildPolicyContext(files)\n\n      expect(ctx.allInTrash).toBe(true)\n    })\n\n    it('should not mark allInTrash if some files are not trashed', () => {\n      const files = [\n        createMockFile('file1', { trashed: true }),\n        createMockFile('file2', { trashed: false })\n      ] as IOCozyFile[]\n\n      const ctx = buildPolicyContext(files)\n\n      expect(ctx.allInTrash).toBe(false)\n    })\n  })\n\n  describe('ACTION_POLICIES', () => {\n    it('should have all expected policies registered', () => {\n      const policyNames = ACTION_POLICIES.map(p => p.name)\n\n      expect(policyNames).toContain('infection')\n      expect(policyNames).toContain('notScanned')\n      expect(policyNames).toContain('multipleFiles')\n      expect(policyNames).toContain('folders')\n      expect(policyNames).toContain('trashed')\n    })\n\n    describe('infection policy', () => {\n      const infectionPolicy = ACTION_POLICIES.find(p => p.name === 'infection')\n\n      if (!infectionPolicy) {\n        throw new Error('infection policy not found')\n      }\n\n      it('should allow action when no infected files', () => {\n        const action = { allowInfectedFiles: false }\n        const ctx = { hasInfectedFile: false } as ActionPolicyContext\n\n        expect(infectionPolicy.allows(action, ctx)).toBe(true)\n      })\n\n      it('should block action when infected files and not allowed', () => {\n        const action = { allowInfectedFiles: false }\n        const ctx = { hasInfectedFile: true } as ActionPolicyContext\n\n        expect(infectionPolicy.allows(action, ctx)).toBe(false)\n      })\n\n      it('should allow action when infected files and explicitly allowed', () => {\n        const action = { allowInfectedFiles: true }\n        const ctx = { hasInfectedFile: true } as ActionPolicyContext\n\n        expect(infectionPolicy.allows(action, ctx)).toBe(true)\n      })\n    })\n\n    describe('notScanned policy', () => {\n      const notScannedPolicy = ACTION_POLICIES.find(\n        p => p.name === 'notScanned'\n      )\n\n      if (!notScannedPolicy) {\n        throw new Error('notScanned policy not found')\n      }\n\n      it('should allow action when no pending files', () => {\n        const action = { allowNotScannedFiles: false }\n        const ctx = { hasNotScannedFile: false } as ActionPolicyContext\n\n        expect(notScannedPolicy.allows(action, ctx)).toBe(true)\n      })\n\n      it('should block action when pending files and not allowed', () => {\n        const action = { allowNotScannedFiles: false }\n        const ctx = { hasNotScannedFile: true } as ActionPolicyContext\n\n        expect(notScannedPolicy.allows(action, ctx)).toBe(false)\n      })\n\n      it('should allow action when pending files and explicitly allowed', () => {\n        const action = { allowNotScannedFiles: true }\n        const ctx = { hasNotScannedFile: true } as ActionPolicyContext\n\n        expect(notScannedPolicy.allows(action, ctx)).toBe(true)\n      })\n    })\n\n    describe('multipleFiles policy', () => {\n      const multipleFilesPolicy = ACTION_POLICIES.find(\n        p => p.name === 'multipleFiles'\n      )\n\n      if (!multipleFilesPolicy) {\n        throw new Error('multipleFiles policy not found in ACTION_POLICIES')\n      }\n\n      it('should allow action for single file by default', () => {\n        const action = {}\n        const ctx = { hasMultipleFiles: false } as ActionPolicyContext\n\n        expect(multipleFilesPolicy.allows(action, ctx)).toBe(true)\n      })\n\n      it('should allow action for multiple files by default', () => {\n        const action = {}\n        const ctx = { hasMultipleFiles: true } as ActionPolicyContext\n\n        expect(multipleFilesPolicy.allows(action, ctx)).toBe(true)\n      })\n\n      it('should block action for multiple files when explicitly disallowed', () => {\n        const action = { allowMultiple: false }\n        const ctx = { hasMultipleFiles: true } as ActionPolicyContext\n\n        expect(multipleFilesPolicy.allows(action, ctx)).toBe(false)\n      })\n    })\n\n    describe('trashed policy', () => {\n      const trashedPolicy = ACTION_POLICIES.find(p => p.name === 'trashed')\n\n      if (!trashedPolicy) {\n        throw new Error('trashedPolicy not found in ACTION_POLICIES')\n      }\n\n      it('should allow action when files not in trash', () => {\n        const action = {}\n        const ctx = { allInTrash: false } as ActionPolicyContext\n\n        expect(trashedPolicy.allows(action, ctx)).toBe(true)\n      })\n\n      it('should block action when files in trash and not allowed', () => {\n        const action = {}\n        const ctx = { allInTrash: true } as ActionPolicyContext\n\n        expect(trashedPolicy.allows(action, ctx)).toBe(false)\n      })\n\n      it('should allow action when files in trash and explicitly allowed', () => {\n        const action = { allowTrashed: true }\n        const ctx = { allInTrash: true } as ActionPolicyContext\n\n        expect(trashedPolicy.allows(action, ctx)).toBe(true)\n      })\n    })\n  })\n\n  describe('filterActionsByPolicy', () => {\n    it('should return all actions when no policy restrictions apply', () => {\n      const actions = [\n        createWrappedAction('download'),\n        createWrappedAction('share'),\n        createWrappedAction('trash')\n      ]\n      const files = [createMockFile('file1')] as IOCozyFile[]\n\n      const result = filterActionsByPolicy(actions, files)\n\n      expect(result).toHaveLength(3)\n    })\n\n    it('should filter out actions blocked by infection policy', () => {\n      const actions = [\n        createWrappedAction('download', { allowInfectedFiles: false }),\n        createWrappedAction('share', { allowInfectedFiles: false }),\n        createWrappedAction('trash', { allowInfectedFiles: true })\n      ]\n      const files = [\n        createMockFile('file1', { infected: true })\n      ] as IOCozyFile[]\n\n      const result = filterActionsByPolicy(actions, files)\n\n      expect(result).toHaveLength(1)\n      expect(Object.keys(result[0])[0]).toBe('trash')\n    })\n\n    it('should filter out actions blocked by multiple files policy', () => {\n      const actions = [\n        createWrappedAction('download'),\n        createWrappedAction('rename', { allowMultiple: false }),\n        createWrappedAction('trash')\n      ]\n      const files = [\n        createMockFile('file1'),\n        createMockFile('file2')\n      ] as IOCozyFile[]\n\n      const result = filterActionsByPolicy(actions, files)\n\n      expect(result).toHaveLength(2)\n      expect(result.map(a => Object.keys(a)[0])).toEqual(['download', 'trash'])\n    })\n\n    it('should filter out actions blocked by trashed policy', () => {\n      const actions = [\n        createWrappedAction('download'),\n        createWrappedAction('restore', { allowTrashed: true }),\n        createWrappedAction('share')\n      ]\n      const files = [createMockFile('file1', { trashed: true })] as IOCozyFile[]\n\n      const result = filterActionsByPolicy(actions, files)\n\n      expect(result).toHaveLength(1)\n      expect(Object.keys(result[0])[0]).toBe('restore')\n    })\n\n    it('should handle empty actions array', () => {\n      const actions: Record<string, DriveAction>[] = []\n      const files = [\n        createMockFile('file1', { infected: true })\n      ] as IOCozyFile[]\n\n      const result = filterActionsByPolicy(actions, files)\n\n      expect(result).toHaveLength(0)\n    })\n\n    it('should handle empty files array', () => {\n      const actions = [\n        createWrappedAction('download'),\n        createWrappedAction('trash')\n      ]\n      const files: IOCozyFile[] = []\n\n      const result = filterActionsByPolicy(actions, files)\n\n      expect(result).toHaveLength(2)\n    })\n\n    it('should allow empty action wrappers (fail-open behavior)', () => {\n      // Test that empty wrappers are allowed through the filter\n      // This verifies the contract that getActionFromWrapper can return null\n      // and isActionAllowedByPolicies is not called for such cases\n      const actions = [\n        createWrappedAction('download'),\n        {} as Record<string, DriveAction>, // Empty wrapper\n        createWrappedAction('trash')\n      ]\n      const files = [createMockFile('file1')] as IOCozyFile[]\n\n      const result = filterActionsByPolicy(actions, files)\n\n      // Empty wrapper should be included in results (fail-open)\n      expect(result).toHaveLength(3)\n      expect(result[1]).toEqual({})\n    })\n\n    it('should apply multiple policies together', () => {\n      const actions = [\n        createWrappedAction('download', { allowInfectedFiles: false }),\n        createWrappedAction('rename', {\n          allowInfectedFiles: true,\n          allowMultiple: false\n        }),\n        createWrappedAction('trash', { allowInfectedFiles: true })\n      ]\n      // Multiple infected files\n      const files = [\n        createMockFile('file1', { infected: true }),\n        createMockFile('file2', { infected: true })\n      ] as IOCozyFile[]\n\n      const result = filterActionsByPolicy(actions, files)\n\n      // download blocked by infection, rename blocked by multiple files\n      expect(result).toHaveLength(1)\n      expect(Object.keys(result[0])[0]).toBe('trash')\n    })\n  })\n\n  describe('hasAnyInfectedFile', () => {\n    it('should return false when no files are infected', () => {\n      const files = [\n        createMockFile('file1'),\n        createMockFile('file2')\n      ] as IOCozyFile[]\n\n      const result = hasAnyInfectedFile(files)\n\n      expect(result).toBe(false)\n    })\n\n    it('should return true when at least one file is infected', () => {\n      const files = [\n        createMockFile('file1', { infected: true }),\n        createMockFile('file2')\n      ] as IOCozyFile[]\n\n      const result = hasAnyInfectedFile(files)\n\n      expect(result).toBe(true)\n    })\n\n    it('should return false for empty array', () => {\n      const files: IOCozyFile[] = []\n\n      const result = hasAnyInfectedFile(files)\n\n      expect(result).toBe(false)\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/actions/policies.ts",
    "content": "import { isDirectory } from 'cozy-client/dist/models/file'\nimport type { IOCozyFile } from 'cozy-client/types/types'\nimport flag from 'cozy-flags'\n\nimport type {\n  ActionPolicyContext,\n  ActionPolicyDefinition,\n  DriveAction,\n  DriveActionPolicyFlags\n} from './types'\n\nimport { isInfected, isNotScanned } from '@/modules/filelist/helpers'\n\n/**\n * Builds the policy context from the selected files.\n * This computes all the information needed for policy checks once,\n * so we don't have to recompute it for each policy.\n *\n * @param {IOCozyFile[]} files - The files being acted upon\n * @returns {ActionPolicyContext} The policy context with computed file information\n */\nexport const buildPolicyContext = (\n  files: IOCozyFile[]\n): ActionPolicyContext => {\n  let hasInfected = false\n  let hasNotScanned = false\n  let hasFolder = false\n  let hasSharedFile = false\n  let allInTrash = files.length > 0\n\n  for (const file of files) {\n    if (!hasInfected && isInfected(file)) hasInfected = true\n    if (!hasNotScanned && isNotScanned(file)) hasNotScanned = true\n    if (!hasFolder && isDirectory(file)) hasFolder = true\n    if (!hasSharedFile) {\n      hasSharedFile =\n        file.referenced_by?.some(ref => ref.type === 'io.cozy.sharings') ??\n        false\n    }\n    if (allInTrash && !file.trashed) allInTrash = false\n  }\n\n  return {\n    files,\n    hasInfectedFile: hasInfected,\n    hasNotScannedFile: flag('drive.not-scanned-file-action.enabled')\n      ? hasNotScanned\n      : false,\n    hasMultipleFiles: files.length > 1,\n    hasFolder,\n    hasSharedFile,\n    allInTrash\n  }\n}\n\n/**\n * Policy for infected files.\n * Actions are blocked for infected files unless they explicitly allow it.\n */\nconst infectionPolicy: ActionPolicyDefinition = {\n  name: 'infection',\n  allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext): boolean =>\n    !ctx.hasInfectedFile || action.allowInfectedFiles === true\n}\n\n/**\n * Policy for files that haven't been scanned yet.\n * Actions are blocked when files are not scanned unless they explicitly allow it.\n */\nconst notScannedPolicy: ActionPolicyDefinition = {\n  name: 'notScanned',\n  allows: (\n    action: DriveActionPolicyFlags,\n    ctx: ActionPolicyContext\n  ): boolean => {\n    const allowed =\n      !ctx.hasNotScannedFile || action.allowNotScannedFiles === true\n    return allowed\n  }\n}\n\n/**\n * Policy for multiple file selection.\n * Actions are blocked for multiple files unless they explicitly allow it.\n * Default is true (most actions support multiple files).\n */\nconst multipleFilesPolicy: ActionPolicyDefinition = {\n  name: 'multipleFiles',\n  allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext): boolean =>\n    !ctx.hasMultipleFiles || action.allowMultiple !== false\n}\n\n/**\n * Policy for folders.\n * Actions are blocked for folders unless they explicitly allow it.\n * Default is true (most actions support folders).\n */\nconst foldersPolicy: ActionPolicyDefinition = {\n  name: 'folders',\n  allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext): boolean =>\n    !ctx.hasFolder || action.allowFolders !== false\n}\n\n/**\n * Policy for trashed files.\n * Actions are blocked for trashed files unless they explicitly allow it.\n */\nconst trashedPolicy: ActionPolicyDefinition = {\n  name: 'trashed',\n  allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext): boolean =>\n    !ctx.allInTrash || action.allowTrashed === true\n}\n\n/**\n * All registered policies that will be checked for each action.\n * Add new policies here to have them automatically applied.\n */\nexport const ACTION_POLICIES: ActionPolicyDefinition[] = [\n  infectionPolicy,\n  notScannedPolicy,\n  multipleFilesPolicy,\n  foldersPolicy,\n  trashedPolicy\n]\n\n/**\n * Extracts the action object from a drive action wrapper.\n * Actions from makeActions are wrapped as { [actionName]: actionObject }\n */\nconst getActionFromWrapper = (\n  wrappedAction: Record<string, DriveAction>\n): DriveAction | null => {\n  const values = Object.values(wrappedAction)\n  return values.length > 0 ? values[0] : null\n}\n\n/**\n * Checks if an action is allowed by all policies.\n *\n * @param action - The action to check\n * @param ctx - The policy context\n * @returns true if all policies allow the action\n */\nconst isActionAllowedByPolicies = (\n  action: DriveAction,\n  ctx: ActionPolicyContext\n): boolean => {\n  return ACTION_POLICIES.every(policy => policy.allows(action, ctx))\n}\n\n/**\n * Filters actions based on all registered policies.\n * This is the single source of truth for determining which actions\n * are available for a given set of files based on their characteristics.\n *\n * @param actions - Array of wrapped actions from makeActions\n * @param files - Array of files to check policies against\n * @returns Filtered array of actions that are allowed for the given files\n *\n * @example\n * ```typescript\n * const filteredActions = filterActionsByPolicy(actions, selectedFiles)\n * ```\n */\nexport const filterActionsByPolicy = <T extends Record<string, DriveAction>>(\n  actions: T[],\n  files: IOCozyFile[]\n): T[] => {\n  // Build the policy context once for all checks\n  const ctx = buildPolicyContext(files)\n\n  const result = actions.filter(wrappedAction => {\n    // makeActions guarantees wrappers contain an action, so empty wrappers\n    // cannot occur. This fail-open behavior is safe and intentional.\n    const action = getActionFromWrapper(wrappedAction)\n    if (!action) return true\n\n    const isAllowed = isActionAllowedByPolicies(action, ctx)\n    return isAllowed\n  })\n\n  return result\n}\n\n/**\n * Checks if any of the provided files are infected.\n * Useful for UI components that need to show infection indicators.\n *\n * @param files - Array of files to check\n * @returns true if any file is infected\n */\nexport const hasAnyInfectedFile = (files: IOCozyFile[]): boolean => {\n  return files.some(file => isInfected(file))\n}\n\n/**\n * Gets the policy context for the given files.\n * Useful for UI components that need to access policy information.\n *\n * @param files - Array of files to build context for\n * @returns The policy context\n */\nexport const getPolicyContext = (files: IOCozyFile[]): ActionPolicyContext => {\n  return buildPolicyContext(files)\n}\n"
  },
  {
    "path": "src/modules/actions/qualify.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport { getQualification } from 'cozy-client/dist/models/document'\nimport { getBoundT } from 'cozy-client/dist/models/document/locales'\nimport { isFile } from 'cozy-client/dist/models/file'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport UnqualifyIcon from 'cozy-ui/transpiled/react/Icons/LabelOutlined'\nimport QualifyIcon from 'cozy-ui/transpiled/react/Icons/Qualify'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { navigateToModal } from '@/modules/actions/helpers'\n\nconst makeComponent = ({ label, scannerT, t }) => {\n  const Component = forwardRef((props, ref) => {\n    const file = props.docs[0]\n    const fileQualif = getQualification(file)\n\n    return (\n      <ActionsMenuItem {...props} ref={ref}>\n        <ListItemIcon>\n          <Icon icon={fileQualif ? QualifyIcon : UnqualifyIcon} />\n        </ListItemIcon>\n        <ListItemText primary={fileQualif ? t('Scan.requalify') : label} />\n        {fileQualif && (\n          <ListItemText\n            secondary={scannerT(`Scan.items.${fileQualif.label}`)}\n            secondaryTypographyProps={{ variant: 'caption' }}\n            className=\"u-ta-right\"\n          />\n        )}\n      </ActionsMenuItem>\n    )\n  })\n  Component.displayName = 'Qualify'\n\n  return Component\n}\n\nexport const qualify = ({ t, lang, navigate, pathname }) => {\n  const label = t('SelectionBar.qualify')\n  const scannerT = getBoundT(lang || 'en')\n\n  return {\n    name: 'qualify',\n    label,\n    icon: QualifyIcon,\n    displayCondition: selection => {\n      return selection.length === 1 && isFile(selection[0])\n    },\n    action: files => {\n      return navigateToModal({ navigate, pathname, files, path: 'qualify' })\n    },\n    Component: makeComponent({ label, scannerT, t })\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/rename.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport RenameIcon from 'cozy-ui/transpiled/react/Icons/Rename'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { startRenamingAsync } from '@/modules/drive/rename'\nimport { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'\n\nconst makeComponent = (label, icon) => {\n  const Component = forwardRef((props, ref) => {\n    return (\n      <ActionsMenuItem {...props} ref={ref}>\n        <ListItemIcon>\n          <Icon icon={icon} />\n        </ListItemIcon>\n        <ListItemText primary={label} />\n      </ActionsMenuItem>\n    )\n  })\n  Component.displayName = 'Rename'\n\n  return Component\n}\n\nexport const rename = ({\n  t,\n  hasWriteAccess,\n  dispatch,\n  shouldHideIfSharedDriveRecipient\n}) => {\n  const label = t('SelectionBar.rename')\n  const icon = RenameIcon\n\n  return {\n    name: 'rename',\n    label,\n    icon,\n    displayCondition: selection => {\n      // special case for rename in sharings tab\n      const isAllowedForSharedDrive = shouldHideIfSharedDriveRecipient\n        ? !isFromSharedDriveRecipient(selection[0])\n        : true\n      return selection.length === 1 && hasWriteAccess && isAllowedForSharedDrive\n    },\n    action: files => {\n      // Use setTimeout to defer dispatch until after click event completes\n      // This prevents focus loss on the rename input\n      setTimeout(() => {\n        dispatch(startRenamingAsync(files[0]))\n      }, 0)\n    },\n    Component: makeComponent(label, icon)\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/restore.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport RestoreIcon from 'cozy-ui/transpiled/react/Icons/Restore'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { restoreFiles } from './utils'\n\nconst makeComponent = (label, icon) => {\n  const Component = forwardRef((props, ref) => {\n    return (\n      <ActionsMenuItem {...props} ref={ref}>\n        <ListItemIcon>\n          <Icon icon={icon} />\n        </ListItemIcon>\n        <ListItemText primary={label} />\n      </ActionsMenuItem>\n    )\n  })\n  Component.displayName = 'Restore'\n\n  return Component\n}\n\nexport const restore = ({ t, refresh, client }) => {\n  const label = t('SelectionBar.restore')\n  const icon = RestoreIcon\n\n  return {\n    name: 'restore',\n    label,\n    icon,\n    allowTrashed: true,\n    action: async files => {\n      await restoreFiles(client, files)\n      refresh()\n    },\n    Component: makeComponent(label, icon)\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/select.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport CheckSquareIcon from 'cozy-ui/transpiled/react/Icons/CheckSquare'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nconst makeComponent = (label, icon) => {\n  const Component = forwardRef((props, ref) => {\n    return (\n      <ActionsMenuItem {...props} ref={ref}>\n        <ListItemIcon>\n          <Icon icon={icon} />\n        </ListItemIcon>\n        <ListItemText primary={label} />\n      </ActionsMenuItem>\n    )\n  })\n  Component.displayName = 'Select'\n\n  return Component\n}\n\nexport const select = ({ t, showSelectionBar }) => {\n  const label = t('toolbar.menu_select')\n  const icon = CheckSquareIcon\n\n  return {\n    name: 'select',\n    label,\n    icon,\n    displayCondition: files => files.length > 1,\n    action: () => showSelectionBar(),\n    Component: makeComponent(label, icon)\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/selectAll.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport CheckSquareIcon from 'cozy-ui/transpiled/react/Icons/CheckSquare'\nimport CheckboxIcon from 'cozy-ui/transpiled/react/Icons/Checkbox'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nconst makeComponent = (label, icon) => {\n  const Component = forwardRef((props, ref) => {\n    return (\n      <ActionsMenuItem {...props} ref={ref}>\n        <ListItemIcon>\n          <Icon icon={icon} />\n        </ListItemIcon>\n        <ListItemText primary={label} />\n      </ActionsMenuItem>\n    )\n  })\n  Component.displayName = 'SelectAllItems'\n\n  return Component\n}\n\nexport const selectAllItems = ({ t, selectAll, isSelectAll, isMobile }) => {\n  const baseKey = isSelectAll ? 'clear_selection' : 'select_all'\n  const label = t(`toolbar.${baseKey}${isMobile ? '_mobile' : ''}`)\n  const icon = isSelectAll ? CheckSquareIcon : CheckboxIcon\n\n  return {\n    name: 'selectAllItems',\n    label,\n    icon,\n    displayInSelectionBar: true,\n    displayInContextMenu: false,\n    displayCondition: files => files.length > 0,\n    action: () => selectAll(),\n    Component: makeComponent(label, icon)\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/share.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport { SharedRecipients } from 'cozy-sharing'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { navigateToModal } from '@/modules/actions/helpers'\nimport { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'\n\nconst share = ({\n  t,\n  shouldHideIfSharedDriveRecipient,\n  hasWriteAccess,\n  navigate,\n  pathname,\n  allLoaded\n}) => {\n  const label = t('Files.share.cta')\n  const icon = ShareIcon\n\n  return {\n    name: 'share',\n    label,\n    icon,\n    allowInfectedFiles: false,\n    displayCondition: files => {\n      // If shared drive recipient:\n      // - in sharing view, we hide it because it works differently\n      // - in shared drive view, we show it\n      if (files?.length === 1 && isFromSharedDriveRecipient(files[0])) {\n        return !shouldHideIfSharedDriveRecipient\n      }\n\n      return (\n        allLoaded && // We need to wait for the sharing context to be completely loaded to avoid race conditions\n        hasWriteAccess &&\n        files?.length === 1\n      )\n    },\n    action: files =>\n      navigateToModal({ navigate, pathname, files, path: 'share' }),\n    Component: forwardRef(function ShareMenuItemInMenu(props, ref) {\n      const { isMobile } = useBreakpoints()\n\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n          {isMobile && props.docs ? (\n            <ListItemIcon classes={{ root: 'u-w-auto' }}>\n              <SharedRecipients docId={props.docs[0].id} size=\"small\" />\n            </ListItemIcon>\n          ) : null}\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { share }\n"
  },
  {
    "path": "src/modules/actions/summariseByAI.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport flag from 'cozy-flags'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ArticleIcon from 'cozy-ui/transpiled/react/Icons/Article'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { isFileSummaryCompatible } from 'cozy-viewer/dist/helpers'\n\nconst makeComponent = (label, icon) => {\n  const Component = forwardRef((props, ref) => {\n    return (\n      <ActionsMenuItem {...props} ref={ref}>\n        <ListItemIcon>\n          <Icon icon={icon} />\n        </ListItemIcon>\n        <ListItemText primary={label} />\n      </ActionsMenuItem>\n    )\n  })\n\n  Component.displayName = 'summariseByAI'\n\n  return Component\n}\n\nexport const summariseByAI = ({ t, hasWriteAccess, navigate, isPublic }) => {\n  const label = t('actions.summariseByAI')\n  const icon = ArticleIcon\n\n  return {\n    name: 'summariseByAI',\n    label,\n    icon,\n    displayCondition: files =>\n      flag('ai.available') &&\n      isFileSummaryCompatible(files[0]) &&\n      hasWriteAccess &&\n      !isPublic,\n    action: files => {\n      const file = files[0]\n      navigate(`file/${file._id}`, {\n        state: { showAIAssistant: true }\n      })\n    },\n    Component: makeComponent(label, icon)\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/trash.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport DeleteConfirm from '@/modules/drive/DeleteConfirm'\n\nconst makeComponent = ({ icon, t, byDocId, isOwner }) => {\n  const Component = forwardRef((props, ref) => {\n    const sharedWithMe =\n      byDocId !== undefined &&\n      byDocId[props.docs[0].id] &&\n      !isOwner(props.docs[0].id)\n\n    const label = sharedWithMe\n      ? t('toolbar.leave')\n      : props.docs.length > 1\n        ? t('SelectionBar.trash_all')\n        : t('SelectionBar.trash')\n\n    return (\n      <ActionsMenuItem {...props} ref={ref}>\n        <ListItemIcon>\n          <Icon icon={icon} color=\"var(--errorColor)\" />\n        </ListItemIcon>\n        <ListItemText\n          primary={label}\n          primaryTypographyProps={{ color: 'error' }}\n        />\n      </ActionsMenuItem>\n    )\n  })\n  Component.displayName = 'Trash'\n\n  return Component\n}\n\nexport const trash = ({\n  t,\n  pushModal,\n  popModal,\n  hasWriteAccess,\n  refresh,\n  byDocId,\n  isOwner,\n  driveId\n}) => {\n  const icon = TrashIcon\n\n  return {\n    name: 'trash',\n    icon,\n    allowInfectedFiles: true,\n    allowNotScannedFiles: true,\n    displayCondition: files => files.length > 0 && hasWriteAccess,\n    action: files => {\n      return pushModal(\n        <DeleteConfirm\n          files={files}\n          afterConfirmation={refresh}\n          onClose={popModal}\n          driveId={driveId}\n        />\n      )\n    },\n    Component: makeComponent({ icon, t, byDocId, isOwner })\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/types.ts",
    "content": "import type { ForwardRefExoticComponent, RefAttributes } from 'react'\n\nimport type { IOCozyFile } from 'cozy-client/types/types'\nimport type { Action as CozyAction } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\n\n/**\n * Context containing computed information about the selected files.\n * This is built once and passed to all policy checks for efficiency.\n */\nexport interface ActionPolicyContext {\n  /** The files being acted upon */\n  files: IOCozyFile[]\n  /** Whether any file in the selection is infected */\n  hasInfectedFile: boolean\n  /** Whether any file has not been scanned yet */\n  hasNotScannedFile: boolean\n  /** Whether multiple files are selected */\n  hasMultipleFiles: boolean\n  /** Whether any file is a folder */\n  hasFolder: boolean\n  /** Whether any file is shared */\n  hasSharedFile: boolean\n  /** Whether all files are in the trash */\n  allInTrash: boolean\n}\n\n/**\n * Interface for defining a policy that determines if an action is allowed.\n * Each policy checks a specific aspect (infection, read-only, etc.).\n */\nexport interface ActionPolicyDefinition {\n  /** Unique name for the policy (for debugging/logging) */\n  name: string\n  /**\n   * Checks if the action is allowed given the policy context.\n   * @param action - The action being checked\n   * @param ctx - The policy context with computed file information\n   * @returns true if the action is allowed, false otherwise\n   */\n  allows: (action: DriveActionPolicyFlags, ctx: ActionPolicyContext) => boolean\n}\n\n/**\n * Policy flags that can be set on actions to control their availability.\n * Each flag corresponds to a policy check.\n */\nexport interface DriveActionPolicyFlags {\n  allowInfectedFiles?: boolean\n  allowNotScannedFiles?: boolean\n  allowMultiple?: boolean\n  allowFolders?: boolean\n  allowTrashed?: boolean\n}\n\n/**\n * Context passed to action handlers and components at runtime.\n */\nexport interface ActionContext {\n  client?: unknown\n  t?: (key: string, options?: Record<string, unknown>) => string\n  lang?: string\n  vaultClient?: unknown\n  pushModal?: (modal: React.ReactNode) => void\n  popModal?: () => void\n  refresh?: () => void\n  navigate?: (\n    path: string | { pathname: string; search?: string },\n    options?: unknown\n  ) => void\n  hasWriteAccess?: boolean\n  canMove?: boolean\n  isPublic?: boolean\n  allLoaded?: boolean\n  showAlert?: (options: { message: string; severity: string }) => void\n  isOwner?: (docId: string) => boolean\n  byDocId?: Record<string, unknown>\n  isNativeFileSharingAvailable?: boolean\n  shareFilesNative?: (files: IOCozyFile[]) => void\n  isSharingShortcutCreated?: boolean\n  openSharingLinkDisplayed?: boolean\n  syncSharingLink?: () => void\n  isMobile?: boolean\n  fetchBlobFileById?: (client: unknown, fileId: string) => Promise<Blob>\n  isFile?: (file: IOCozyFile) => boolean\n  addSharingLink?: () => void\n  driveId?: string\n  pathname?: string\n  search?: string\n  canDuplicate?: boolean\n  isSelectAll?: boolean\n  displayedFolder?: IOCozyFile\n}\n\n/**\n * Props passed to action menu item components.\n */\nexport interface ActionComponentProps {\n  docs?: IOCozyFile[]\n  onClick?: (context?: unknown) => void\n}\n\n/**\n * Drive action definition with policy support.\n */\nexport interface DriveAction extends DriveActionPolicyFlags {\n  /** Unique identifier for the action */\n  name: string\n  /** Display label for the action */\n  label?: string\n  /** Icon component or icon name */\n  icon?: React.ComponentType | string\n  /**\n   * Function to determine if the action should be displayed.\n   * This is checked AFTER policy checks.\n   */\n  displayCondition?: (docs: IOCozyFile[]) => boolean\n  /** Whether to show this action in the selection bar. Default: true */\n  displayInSelectionBar?: boolean\n  /** Whether to show this action in context menus. Default: true */\n  displayInContextMenu?: boolean\n  /** The action handler */\n  action?: (docs: IOCozyFile[], context?: ActionContext) => void\n  /** React component to render the action menu item */\n  Component?: ForwardRefExoticComponent<\n    ActionComponentProps & RefAttributes<HTMLLIElement>\n  >\n}\n\n/**\n * Extended Action type that includes policy properties.\n * Use this type when you need to return an action that is compatible\n * with cozy-ui's Action type but also includes our policy properties.\n */\nexport type ActionWithPolicy<T = IOCozyFile> = CozyAction<T> &\n  DriveActionPolicyFlags\n"
  },
  {
    "path": "src/modules/actions/utils.js",
    "content": "import { isDirectory } from 'cozy-client/dist/models/file'\nimport { receiveQueryResult } from 'cozy-client/dist/store'\n\nimport { DOCTYPE_FILES } from '@/lib/doctypes'\n\nconst isMissingFileError = error => error.status === 404\n\nconst downloadFileError = error => {\n  return isMissingFileError(error)\n    ? 'error.download_file.missing'\n    : 'error.download_file.offline'\n}\n\n/**\n * An instance of cozy-client\n * @typedef {object} CozyClient\n */\n\n/**\n * downloadFiles - Triggers the download of one or multiple files by the browser\n *\n * @param {CozyClient} client\n * @param {array} files  One or more files to download\n */\nexport const downloadFiles = async (client, files, { showAlert, t } = {}) => {\n  if (files.length === 1 && !isDirectory(files[0])) {\n    const file = files[0]\n    const driveId = file.driveId\n    try {\n      return await client\n        .collection(DOCTYPE_FILES, { driveId })\n        .download(file, null, file.name)\n    } catch (error) {\n      showAlert({ message: t(downloadFileError(error)), severity: 'error' })\n    }\n  } else {\n    const ids = files.map(f => f.id)\n    const driveId = files[0].driveId\n    return client.collection(DOCTYPE_FILES, { driveId }).downloadArchive(ids)\n  }\n}\n\nconst isAlreadyInTrash = err => {\n  const reasons = err.reason !== undefined ? err.reason.errors : undefined\n  if (reasons) {\n    for (const reason of reasons) {\n      if (reason.detail === 'File or directory is already in the trash') {\n        return true\n      }\n    }\n  }\n  return false\n}\n\n/**\n * trashFiles - Moves a set of files to the cozy trash\n *\n * @param {CozyClient} client\n * @param {array} files  One or more files to trash\n */\nexport const trashFiles = async (client, files, { showAlert, t, driveId }) => {\n  try {\n    for (const file of files) {\n      // TODO we should not go through a FileCollection to destroy the file, but\n      // only do client.destroy(), I do not know what it did not update the internal\n      // store correctly when I tried\n      const { data: updatedFile } = await client\n        .collection(DOCTYPE_FILES, { driveId })\n        .destroy(file)\n      client.store.dispatch(\n        receiveQueryResult(null, {\n          data: updatedFile\n        })\n      )\n      client.collection('io.cozy.permissions').revokeSharingLink(file)\n    }\n\n    showAlert({ message: t('alert.trash_file_success'), severity: 'success' })\n  } catch (err) {\n    if (!isAlreadyInTrash(err)) {\n      showAlert({ message: t('alert.try_again'), severity: 'error' })\n    }\n  }\n}\n\nexport const restoreFiles = async (client, files) => {\n  for (const file of files) {\n    await client.collection(DOCTYPE_FILES).restore(file.id)\n  }\n}\n"
  },
  {
    "path": "src/modules/actions/utils.spec.js",
    "content": "import { createMockClient } from 'cozy-client'\nimport { initQuery, receiveQueryResult } from 'cozy-client/dist/store'\n\nimport { trashFiles, downloadFiles } from './utils'\nimport { generateFile } from 'test/generate'\n\nimport { TRASH_DIR_ID } from '@/constants/config'\n\njest.mock('modules/navigation/AppRoute', () => ({\n  routes: []\n}))\n\njest.mock('cozy-stack-client/dist/utils', () => ({\n  forceFileDownload: jest.fn()\n}))\n\nconst showAlert = jest.fn()\nconst t = x => x\n\ndescribe('trashFiles', () => {\n  const setup = () => {\n    const client = new createMockClient({})\n    const store = client.store\n\n    store.dispatch(\n      initQuery('files', {\n        doctype: 'io.cozy.files'\n      })\n    )\n\n    const file = generateFile({ i: 0 })\n    store.dispatch(\n      receiveQueryResult('files', {\n        data: file\n      })\n    )\n    return { client, store, file }\n  }\n\n  it('should destroy the file and update queries', async () => {\n    const { store, client, file } = setup()\n    const mockedDestroy = jest.fn()\n    client.collection = jest.fn(() => ({\n      destroy: mockedDestroy\n    }))\n\n    mockedDestroy.mockResolvedValue({\n      data: {\n        ...file,\n        dir_id: TRASH_DIR_ID\n      }\n    })\n    const state = store.getState()\n\n    expect(state.cozy.documents['io.cozy.files'][file._id]._id).toEqual(\n      file._id\n    )\n\n    await trashFiles(client, [file], { showAlert, t })\n    expect(mockedDestroy).toHaveBeenCalledWith(file)\n\n    const state2 = store.getState()\n    const updatedFile = state2.cozy.documents['io.cozy.files'][file._id]\n    expect(updatedFile.dir_id).toEqual('io.cozy.files.trash-dir')\n  })\n})\n\ndescribe('downloadFiles', () => {\n  const mockClient = createMockClient({})\n  mockClient.stackClient.uri = 'http://cozy.tools'\n  const mockDownload = jest.fn()\n  const mockDownloadArchive = jest.fn()\n\n  beforeEach(() => {\n    mockClient.collection = () => ({\n      download: mockDownload,\n      downloadArchive: mockDownloadArchive\n    })\n  })\n\n  it('downloads a single file', async () => {\n    const file = {\n      id: 'file-id-1',\n      name: 'my-file.pdf',\n      type: 'file'\n    }\n    await downloadFiles(mockClient, [file])\n    expect(mockDownload).toHaveBeenCalledWith(file, null, file.name)\n  })\n\n  it('downloads a folder', async () => {\n    const folder = {\n      id: 'folder-id-1',\n      name: 'Classified',\n      type: 'directory'\n    }\n    await downloadFiles(mockClient, [folder])\n    expect(mockDownloadArchive).toHaveBeenCalledWith([folder.id])\n  })\n\n  it('downloads multiple files', async () => {\n    const files = [\n      { id: 'file-id-1', name: 'my-file-1.pdf', type: 'file' },\n      { id: 'file-id-2', name: 'my-file-2.pdf', type: 'file' }\n    ]\n    await downloadFiles(mockClient, files)\n    expect(mockDownloadArchive).toHaveBeenCalledWith(['file-id-1', 'file-id-2'])\n  })\n})\n"
  },
  {
    "path": "src/modules/actions/versions.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport { isFile } from 'cozy-client/dist/models/file'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport HistoryIcon from 'cozy-ui/transpiled/react/Icons/History'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { navigateToModal } from '@/modules/actions/helpers'\n\nconst makeComponent = (label, icon) => {\n  const Component = forwardRef((props, ref) => {\n    return (\n      <ActionsMenuItem {...props} ref={ref}>\n        <ListItemIcon>\n          <Icon icon={icon} />\n        </ListItemIcon>\n        <ListItemText primary={label} />\n      </ActionsMenuItem>\n    )\n  })\n  Component.displayName = 'Versions'\n\n  return Component\n}\n\nexport const versions = ({ t, navigate, pathname }) => {\n  const label = t('SelectionBar.history')\n  const icon = HistoryIcon\n\n  return {\n    name: 'history',\n    label,\n    icon,\n    allowInfectedFiles: false,\n    displayCondition: selection => {\n      return selection.length === 1 && isFile(selection[0])\n    },\n    action: files => {\n      return navigateToModal({ navigate, pathname, files, path: 'revision' })\n    },\n    Component: makeComponent(label, icon)\n  }\n}\n"
  },
  {
    "path": "src/modules/breadcrumb/components/Breadcrumb.jsx",
    "content": "import cx from 'classnames'\nimport PropTypes from 'prop-types'\nimport React, { useState, useRef, useEffect, useCallback } from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport RightIcon from 'cozy-ui/transpiled/react/Icons/Right'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/modules/breadcrumb/styles/breadcrumb.styl'\n\nconst Breadcrumb = ({\n  path,\n  onBreadcrumbClick,\n  opening,\n  inlined,\n  className = ''\n}) => {\n  const { t } = useI18n()\n  const [deployed, setDeployed] = useState(false)\n  const wrapperRef = useRef(null)\n\n  const closeMenu = useCallback(() => {\n    setDeployed(false)\n  }, [setDeployed])\n\n  const openMenu = useCallback(() => {\n    setDeployed(true)\n  }, [setDeployed])\n\n  useEffect(() => {\n    function handleClickOutside(event) {\n      if (wrapperRef.current && !wrapperRef.current.contains(event.target)) {\n        closeMenu()\n      }\n    }\n    document.addEventListener('mousedown', handleClickOutside)\n    return () => {\n      document.removeEventListener('mousedown', handleClickOutside)\n    }\n  }, [wrapperRef, closeMenu])\n\n  const toggleDeploy = () => (deployed ? closeMenu() : openMenu())\n\n  if (!path) return false\n\n  return (\n    <div\n      className={cx(\n        styles['fil-path-backdrop'],\n        { [styles['deployed']]: deployed },\n        { [styles['inlined']]: inlined },\n        className\n      )}\n    >\n      <h2\n        data-testid=\"path-title\"\n        className={styles['fil-path-title']}\n        ref={wrapperRef}\n        onClick={toggleDeploy}\n      >\n        {path.map((folder, index) => {\n          const folderName =\n            folder._id === 'io.cozy.files.shared-drives-dir'\n              ? t('breadcrumb.title_shared_drives')\n              : folder.name\n          if (index < path.length - 1) {\n            return (\n              <span\n                className={styles['fil-path-link']}\n                onClick={e => {\n                  e.stopPropagation()\n                  onBreadcrumbClick(folder)\n                }}\n                key={index}\n              >\n                <span className={styles['fil-path-link-name']}>\n                  {folderName}\n                </span>\n                <Icon\n                  icon={RightIcon}\n                  className={styles['fil-path-separator']}\n                />\n              </span>\n            )\n          } else {\n            return (\n              <span\n                className={styles['fil-path-current']}\n                onClick={e => {\n                  e.stopPropagation()\n                  if (path.length >= 2) toggleDeploy()\n                }}\n                key={index}\n              >\n                <span className={styles['fil-path-current-name']}>\n                  {folderName}\n                </span>\n                {path.length >= 2 && (\n                  <span className={styles['fil-path-down']} />\n                )}\n\n                {opening && <Spinner />}\n              </span>\n            )\n          }\n        })}\n      </h2>\n    </div>\n  )\n}\n\nBreadcrumb.propTypes = {\n  path: PropTypes.array,\n  onBreadcrumbClick: PropTypes.func,\n  opening: PropTypes.bool,\n  inlined: PropTypes.bool,\n  className: PropTypes.string\n}\n\nexport default Breadcrumb\n"
  },
  {
    "path": "src/modules/breadcrumb/components/Breadcrumb.spec.jsx",
    "content": "import { fireEvent, render } from '@testing-library/react'\nimport React from 'react'\n\nimport Breadcrumb from './Breadcrumb'\nimport { TestI18n } from 'test/components/AppLike'\nimport { dummyBreadcrumbPathWithRootLarge } from 'test/dummies/dummyBreadcrumbPath'\n\ndescribe('Breadcrumbs', () => {\n  const dummyPath = dummyBreadcrumbPathWithRootLarge()\n\n  const setup = ({ path, inlined, onBreadcrumbClick } = {}) => {\n    return render(\n      <TestI18n>\n        <Breadcrumb\n          path={path}\n          inlined={inlined}\n          onBreadcrumbClick={onBreadcrumbClick}\n        />\n      </TestI18n>\n    )\n  }\n\n  describe('template', () => {\n    it('should match snapshot', () => {\n      // When\n      const { container } = setup({ path: dummyPath })\n\n      // Then\n      expect(container).toMatchSnapshot()\n    })\n\n    it('should be empty while path is undefined', () => {\n      // When\n      const { container } = setup()\n\n      // Then\n      expect(container).toBeEmptyDOMElement()\n    })\n\n    it('should add inlined style while inlined prop true', () => {\n      // When\n      const { container } = setup({ path: dummyPath, inlined: true })\n\n      // Then\n      expect(container.querySelector('.inlined')).not.toBeEmptyDOMElement()\n    })\n  })\n\n  describe('events', () => {\n    it('should fire on breadcrumb click when link is clicked', () => {\n      // Given\n      const onBreadcrumbClick = jest.fn()\n      const { container } = setup({ path: dummyPath, onBreadcrumbClick })\n\n      // When\n      fireEvent.click(container.querySelector('.fil-path-link'))\n\n      // Then\n      expect(onBreadcrumbClick).toHaveBeenCalledWith({\n        id: 'io.cozy.files.root-dir',\n        name: 'Drive'\n      })\n    })\n\n    it('should toggle deploy on click on current', () => {\n      // Given\n      document.addEventListener = jest.fn()\n\n      // Given\n      const { container } = setup({ path: dummyPath })\n\n      // When\n      fireEvent.click(container.querySelector('.fil-path-current'))\n\n      // Then\n      expect(container.querySelector('.deployed')).toBeInTheDocument()\n      expect(document.addEventListener).toHaveBeenCalledWith(\n        'mousedown',\n        expect.any(Function)\n      )\n    })\n\n    it('should close menu', () => {\n      // Given\n      document.removeEventListener = jest.fn()\n      const { container } = setup({ path: dummyPath })\n      fireEvent.click(container.querySelector('.fil-path-current'))\n\n      expect(container.querySelector('.deployed')).toBeInTheDocument()\n\n      // When\n      fireEvent.click(container.querySelector('.fil-path-current'))\n\n      // Then\n      expect(container.querySelector('.deployed')).not.toBeInTheDocument()\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/breadcrumb/components/DesktopBreadcrumb.jsx",
    "content": "import React, { useEffect, useMemo, useState } from 'react'\n\nimport ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport BreadcrumbMui from 'cozy-ui/transpiled/react/Breadcrumbs'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport FileTypeSharedDriveIcon from 'cozy-ui/transpiled/react/Icons/FileTypeSharedDriveGrey'\nimport FolderIcon from 'cozy-ui/transpiled/react/Icons/Folder'\nimport RightIcon from 'cozy-ui/transpiled/react/Icons/Right'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/modules/breadcrumb/styles/breadcrumb.styl'\n\nimport { ROOT_DIR_ID } from '@/constants/config'\nimport { DesktopBreadcrumbItem } from '@/modules/breadcrumb/components/DesktopBreadcrumbItem'\n\nconst DesktopBreadcrumb = ({ onBreadcrumbClick, path }) => {\n  const { t } = useI18n()\n\n  const expandText = useMemo(() => t('breadcrumb.label'), [t])\n  const [dropdownTrigger, setDropdownTrigger] = useState(\n    document.querySelector(`[aria-label=\"${expandText}\"]`)\n  )\n  const anchorElRef = useMemo(\n    () => ({ current: dropdownTrigger }),\n    [dropdownTrigger]\n  )\n  const [menuDisplayed, setMenuDisplayed] = useState(false)\n\n  const closeMenu = () => setMenuDisplayed(false)\n\n  const handleDropdownTriggerClick = e => {\n    e.stopPropagation()\n    setMenuDisplayed(true)\n  }\n\n  useEffect(() => {\n    closeMenu()\n    setDropdownTrigger(document.querySelector(`[aria-label=\"${expandText}\"]`))\n  }, [path]) // eslint-disable-line react-hooks/exhaustive-deps\n\n  useEffect(() => {\n    const trigger = anchorElRef.current\n    if (trigger) {\n      trigger.addEventListener('click', handleDropdownTriggerClick)\n      return () => {\n        closeMenu()\n        trigger.removeEventListener('click', handleDropdownTriggerClick)\n      }\n    }\n  }, [anchorElRef.current]) // eslint-disable-line react-hooks/exhaustive-deps, react-hooks/refs\n\n  const Separator = (\n    <Icon icon={RightIcon} className={styles['fil-path-separator']} />\n  )\n\n  // When we are in a shared drive, we want to display the shared drive icon\n  // in first position to reduce the number of displayed path elements\n  const pathToDisplay = useMemo(() => {\n    const sharedDriveIndex = path.findIndex(\n      item => item.id === 'io.cozy.files.shared-drives-dir'\n    )\n    if (sharedDriveIndex !== -1 && path.length > 2) {\n      return path.slice(sharedDriveIndex)\n    }\n\n    return path\n  }, [path])\n\n  return (\n    <>\n      <BreadcrumbMui\n        className={styles['fil-path-backdrop']}\n        maxItems={3}\n        separator={Separator}\n        itemsAfterCollapse={2}\n        expandText={expandText}\n      >\n        {pathToDisplay.map((breadcrumbPath, index) => {\n          if (pathToDisplay.length > 1 && breadcrumbPath.id === ROOT_DIR_ID) {\n            return (\n              <DesktopBreadcrumbItem\n                key={breadcrumbPath.name}\n                onClick={onBreadcrumbClick}\n                item={breadcrumbPath}\n                isCurrent={index === pathToDisplay.length - 1}\n                icon={FolderIcon}\n              />\n            )\n          }\n\n          if (\n            index === 0 &&\n            breadcrumbPath.id === 'io.cozy.files.shared-drives-dir'\n          ) {\n            return (\n              <DesktopBreadcrumbItem\n                key={breadcrumbPath.name}\n                onClick={onBreadcrumbClick}\n                item={breadcrumbPath}\n                isCurrent={index === pathToDisplay.length - 1}\n                icon={FileTypeSharedDriveIcon}\n              />\n            )\n          }\n\n          return (\n            <DesktopBreadcrumbItem\n              key={breadcrumbPath.name}\n              onClick={onBreadcrumbClick}\n              item={breadcrumbPath}\n              isCurrent={index === pathToDisplay.length - 1}\n            />\n          )\n        })}\n      </BreadcrumbMui>\n\n      {menuDisplayed && (\n        <ActionsMenu\n          open\n          // eslint-disable-next-line react-hooks/refs\n          ref={anchorElRef}\n          onClose={closeMenu}\n          actions={[]}\n          docs={[]}\n          anchorOrigin={{\n            vertical: 'bottom',\n            horizontal: 'left'\n          }}\n        >\n          {path.slice(1, -2).map(breadcrumbPath => (\n            <ActionsMenuItem\n              key={breadcrumbPath.name}\n              onClick={e => {\n                e.stopPropagation()\n                onBreadcrumbClick(breadcrumbPath)\n              }}\n            >\n              <ListItemText primary={breadcrumbPath.name} />\n            </ActionsMenuItem>\n          ))}\n        </ActionsMenu>\n      )}\n    </>\n  )\n}\n\nexport default DesktopBreadcrumb\n"
  },
  {
    "path": "src/modules/breadcrumb/components/DesktopBreadcrumb.spec.jsx",
    "content": "import { render, fireEvent, act } from '@testing-library/react'\nimport React from 'react'\n\nimport { BreakpointsProvider } from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport DesktopBreadcrumb from './DesktopBreadcrumb'\nimport {\n  dummyBreadcrumbPathNoRootLarge,\n  dummyBreadcrumbPathNoRootSmall,\n  dummyBreadcrumbPathWithRootLarge,\n  dummyBreadcrumbPathWithRootSmall,\n  dummyBreadcrumbPathWithSharedDriveLarge,\n  dummyBreadcrumbPathWithSharedDriveSmall\n} from 'test/dummies/dummyBreadcrumbPath'\n\njest.mock('cozy-ui/transpiled/react/ActionsMenu', () => ({ children }) => (\n  <div data-testid=\"action-menu\">{children}</div>\n))\njest.mock(\n  'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem',\n  () =>\n    ({ children }) => <div data-testid=\"action-menu-item\">{children}</div>\n)\n\njest.mock('twake-i18n')\ndescribe('DesktopBreadcrumb', () => {\n  beforeEach(() => {\n    useI18n.mockReturnValue({ t: () => 'Show path' })\n  })\n\n  describe('template', () => {\n    describe('When parent is ROOT folder', () => {\n      it('should display breadcrumb with | 📁 > \"...\" > parent > current | when more than 3 nested folders', () => {\n        // When\n        const { container, queryByText } = render(\n          <DesktopBreadcrumb path={dummyBreadcrumbPathWithRootLarge()} />\n        )\n\n        // Then\n        expect(container.querySelector('[aria-label=\"Drive\"]')).toBeTruthy()\n        expect(queryByText('grandparent')).toBeFalsy()\n        expect(queryByText('parent')).toBeTruthy()\n        expect(queryByText('current')).toBeTruthy()\n        expect(container.querySelector('[aria-label=\"Show path\"]')).toBeTruthy()\n        expect(container.querySelector('.fil-path-separator')).toBeTruthy()\n      })\n\n      it('should display breadcrumb with | 📁 > parent > current | when 3 nested folders or less', () => {\n        // When\n        const { container, queryByText } = render(\n          <DesktopBreadcrumb path={dummyBreadcrumbPathWithRootSmall()} />\n        )\n\n        // Then\n        expect(container.querySelector('[aria-label=\"Drive\"]')).toBeTruthy()\n        expect(queryByText('grandparent')).toBeFalsy()\n        expect(queryByText('parent')).toBeTruthy()\n        expect(queryByText('current')).toBeTruthy()\n        expect(container.querySelector('[aria-label=\"Show path\"]')).toBeFalsy()\n        expect(container.querySelector('.fil-path-separator')).toBeTruthy()\n      })\n    })\n\n    describe('When parent is a Shared drive', () => {\n      it('should display breadcrumb with | 📁 > \"...\" > parent > current | when more than 3 nested folders', () => {\n        // When\n        const { container, queryByText } = render(\n          <DesktopBreadcrumb path={dummyBreadcrumbPathWithSharedDriveLarge()} />\n        )\n\n        // Then\n        expect(\n          container.querySelector('[aria-label=\"Shared Drive\"]')\n        ).toBeTruthy()\n        expect(queryByText('grandparent')).toBeFalsy()\n        expect(queryByText('parent')).toBeTruthy()\n        expect(queryByText('current')).toBeTruthy()\n        expect(container.querySelector('[aria-label=\"Show path\"]')).toBeTruthy()\n        expect(container.querySelector('.fil-path-separator')).toBeTruthy()\n      })\n\n      it('should display breadcrumb with | 📁 > parent > current | when 3 nested folders or less', () => {\n        // When\n        const { container, queryByText } = render(\n          <DesktopBreadcrumb path={dummyBreadcrumbPathWithSharedDriveSmall()} />\n        )\n\n        // Then\n        expect(\n          container.querySelector('[aria-label=\"Shared Drive\"]')\n        ).toBeTruthy()\n        expect(queryByText('grandparent')).toBeFalsy()\n        expect(queryByText('parent')).toBeTruthy()\n        expect(queryByText('current')).toBeTruthy()\n        expect(container.querySelector('[aria-label=\"Show path\"]')).toBeFalsy()\n        expect(container.querySelector('.fil-path-separator')).toBeTruthy()\n      })\n    })\n\n    describe('When parent is nor ROOT nor Shared drive', () => {\n      it('should display breadcrumb with | Drive > \"...\" > parent > current | when more than 3 nested folders', () => {\n        // When\n        const { container, queryByText } = render(\n          <DesktopBreadcrumb path={dummyBreadcrumbPathNoRootLarge()} />\n        )\n\n        // Then\n        expect(queryByText('Some Main Folder')).toBeTruthy()\n        expect(queryByText('grandparent')).toBeFalsy()\n        expect(queryByText('parent')).toBeTruthy()\n        expect(queryByText('current')).toBeTruthy()\n        expect(container.querySelector('[aria-label=\"Show path\"]')).toBeTruthy()\n        expect(container.querySelector('.fil-path-separator')).toBeTruthy()\n      })\n\n      it('should display breadcrumb with | Drive > parent > current | when 3 nested folders or less', () => {\n        // When\n        const { container, queryByText } = render(\n          <DesktopBreadcrumb path={dummyBreadcrumbPathNoRootSmall()} />\n        )\n\n        // Then\n        expect(queryByText('Some Main Folder')).toBeTruthy()\n        expect(queryByText('parent')).toBeTruthy()\n        expect(queryByText('current')).toBeTruthy()\n        expect(container.querySelector('[aria-label=\"Show path\"]')).toBeFalsy()\n        expect(container.querySelector('.fil-path-separator')).toBeTruthy()\n      })\n    })\n\n    it('should have convenient style on Public view - on desktop', () => {\n      // When\n      const { container } = render(\n        <DesktopBreadcrumb path={dummyBreadcrumbPathWithRootLarge()} />\n      )\n\n      // Then\n      expect(container.querySelector('.fil-path-backdrop')).toBeTruthy()\n    })\n  })\n\n  describe('mount', () => {\n    beforeEach(() => {\n      jest.spyOn(console, 'error').mockImplementation(() => {})\n    })\n    afterEach(() => {\n      // eslint-disable-next-line no-console\n      console.error.mockRestore()\n    })\n    it('should hide menu displayed while navigating', async () => {\n      // Given\n      const { container, queryByTestId, rerender } = await render(\n        <DesktopBreadcrumb path={dummyBreadcrumbPathWithRootLarge()} />\n      )\n      act(() => {\n        container.querySelector('[aria-label=\"Show path\"]').click()\n      })\n      expect(queryByTestId('action-menu')).toBeInTheDocument()\n\n      // When\n      rerender(<DesktopBreadcrumb path={dummyBreadcrumbPathWithRootLarge()} />)\n\n      // Then\n      expect(queryByTestId('action-menu')).not.toBeInTheDocument()\n    })\n\n    it('should update dropdown trigger while navigating - on public page', async () => {\n      // Given\n      const { container, rerender } = await render(\n        <DesktopBreadcrumb path={[]} />\n      )\n\n      expect(container.querySelector('[aria-label=\"Show path\"]')).toBeNull()\n\n      rerender(<DesktopBreadcrumb path={dummyBreadcrumbPathWithRootLarge()} />)\n\n      // When\n      act(() => {\n        container.querySelector('[aria-label=\"Show path\"]').click()\n      })\n\n      // Then\n      expect(container.querySelector('[aria-label=\"Show path\"]')).not.toBeNull()\n    })\n  })\n\n  describe('events', () => {\n    it('should dispatch on breadcrumb click - on desktop', () => {\n      // Given\n      const onBreadcrumbClick = jest.fn()\n      const path = dummyBreadcrumbPathWithRootLarge()\n\n      const { queryByText } = render(\n        <DesktopBreadcrumb path={path} onBreadcrumbClick={onBreadcrumbClick} />\n      )\n\n      // When\n      queryByText('parent').click()\n\n      // Then\n      expect(onBreadcrumbClick).toHaveBeenCalledWith(path[2])\n    })\n\n    it('should display action menu on click on \"...\" on desktop', () => {\n      // Given\n      const path = dummyBreadcrumbPathWithRootLarge()\n\n      const { container, queryByTestId } = render(\n        <BreakpointsProvider>\n          <DesktopBreadcrumb path={path} />\n        </BreakpointsProvider>\n      )\n\n      // When\n      act(() => {\n        container.querySelector('[aria-label=\"Show path\"]').click()\n      })\n\n      // Then\n      expect(queryByTestId('action-menu')).toBeInTheDocument()\n      expect(queryByTestId('action-menu-item')).toBeInTheDocument()\n    })\n\n    it('should add grandParents only in dropdown - on click on ... on desktop', () => {\n      // Given\n      const path = dummyBreadcrumbPathWithRootLarge()\n\n      const { container, queryByText } = render(\n        <BreakpointsProvider>\n          <DesktopBreadcrumb path={path} />\n        </BreakpointsProvider>\n      )\n\n      // When\n      act(() => {\n        container.querySelector('[aria-label=\"Show path\"]').click()\n      })\n\n      // Then\n      expect(container.querySelectorAll('.MuiBreadcrumbs-li')[1]).not.toEqual(\n        'grandParents'\n      )\n      expect(queryByText('grandParent')).toBeInTheDocument()\n    })\n\n    it('should handle on click outside on desktop - removing dropdown', () => {\n      // Given\n      const path = dummyBreadcrumbPathWithRootLarge()\n\n      const { container, queryByText } = render(\n        <div>\n          <button onClick={jest.fn()} />\n          <DesktopBreadcrumb path={path} />\n        </div>\n      )\n\n      // When\n      fireEvent.click(container.querySelector('button'))\n\n      // Then\n      expect(queryByText('grandParent')).not.toBeInTheDocument()\n      expect(container.querySelector('.dropdown')).not.toBeInTheDocument()\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/breadcrumb/components/DesktopBreadcrumbItem.jsx",
    "content": "import classNames from 'classnames'\nimport React, { useCallback } from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/modules/breadcrumb/styles/breadcrumb.styl'\n\nconst DesktopBreadcrumbItem = ({ item, isCurrent, onClick, icon }) => {\n  const { t } = useI18n()\n  const handleClick = useCallback(\n    e => {\n      e.stopPropagation()\n      onClick(item)\n    },\n    [onClick, item]\n  )\n\n  const itemName =\n    item.id === 'io.cozy.files.shared-drives-dir'\n      ? t('breadcrumb.title_shared_drives')\n      : item.name\n\n  return (\n    <span\n      className={classNames(\n        isCurrent ? styles['fil-path-current-name'] : styles['fil-path-link'],\n        styles['fil-path-title']\n      )}\n      key={item.name}\n      onClick={handleClick}\n    >\n      {icon ? (\n        <IconButton size=\"small\" aria-label={item.name}>\n          <Icon icon={icon} />\n        </IconButton>\n      ) : (\n        itemName\n      )}\n    </span>\n  )\n}\n\nexport { DesktopBreadcrumbItem }\n"
  },
  {
    "path": "src/modules/breadcrumb/components/MobileAwareBreadcrumb.jsx",
    "content": "import React from 'react'\n\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport DesktopBreadcrumb from '@/modules/breadcrumb/components/DesktopBreadcrumb'\nimport MobileBreadcrumb from '@/modules/breadcrumb/components/MobileBreadcrumb'\n\nexport const MobileAwareBreadcrumb = props => {\n  const { isMobile } = useBreakpoints()\n\n  return isMobile ? (\n    <MobileBreadcrumb {...props} />\n  ) : (\n    <DesktopBreadcrumb {...props} />\n  )\n}\n\nexport default MobileAwareBreadcrumb\n"
  },
  {
    "path": "src/modules/breadcrumb/components/MobileAwareBreadcrumb.spec.jsx",
    "content": "import { render } from '@testing-library/react'\nimport React from 'react'\n\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport MobileAwareBreadcrumb from './MobileAwareBreadcrumb'\n\njest.mock('cozy-ui/transpiled/react/providers/Breakpoints')\n\njest.mock('modules/breadcrumb/components/DesktopBreadcrumb', () => () => (\n  <div data-testid=\"desktop-breadcrumb\" />\n))\n\njest.mock('modules/breadcrumb/components/MobileBreadcrumb', () => () => (\n  <div data-testid=\"mobile-breadcrumb\" />\n))\n\ndescribe('MobileAwareBreadcrumb', () => {\n  it('should return mobile breadcrumb on mobile', () => {\n    // Given\n    useBreakpoints.mockReturnValue({ isMobile: true })\n\n    // When\n    const { getByTestId } = render(<MobileAwareBreadcrumb />)\n\n    // Then\n    expect(getByTestId('mobile-breadcrumb')).toBeInTheDocument()\n  })\n\n  it('should return mobile breadcrumb on desktop', () => {\n    // Given\n    useBreakpoints.mockReturnValue({ isMobile: false })\n\n    // When\n    const { getByTestId } = render(<MobileAwareBreadcrumb />)\n\n    // Then\n    expect(getByTestId('desktop-breadcrumb')).toBeInTheDocument()\n  })\n})\n"
  },
  {
    "path": "src/modules/breadcrumb/components/MobileBreadcrumb.jsx",
    "content": "import React, { useCallback } from 'react'\n\nimport { BarCenter, BarLeft } from 'cozy-bar'\n\nimport BackButton from '@/components/Button/BackButton'\nimport Breadcrumb from '@/modules/breadcrumb/components/Breadcrumb'\n\nconst MobileBreadcrumb = ({ onBreadcrumbClick, path, ...props }) => {\n  const navigateBack = useCallback(() => {\n    const parentFolder = path[path.length - 2]\n    onBreadcrumbClick(parentFolder)\n  }, [onBreadcrumbClick, path])\n\n  return (\n    <div>\n      {path.length >= 2 && (\n        <BarLeft>\n          <BackButton onClick={navigateBack} />\n        </BarLeft>\n      )}\n      <BarCenter>\n        <Breadcrumb\n          {...props}\n          path={path}\n          onBreadcrumbClick={onBreadcrumbClick}\n        />\n      </BarCenter>\n    </div>\n  )\n}\n\nexport default MobileBreadcrumb\n"
  },
  {
    "path": "src/modules/breadcrumb/components/MobileBreadcrumb.spec.jsx",
    "content": "import { render, fireEvent } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\n\nimport MobileBreadcrumb from './MobileBreadcrumb'\nimport AppLike from 'test/components/AppLike'\n\ndescribe('MobileBreadcrumb', () => {\n  it('works', async () => {\n    const path = [\n      { id: '1', name: 'root folder' },\n      { id: '2', name: 'parent folder' },\n      { id: '3', name: 'current folder' }\n    ]\n\n    const onBreadcrumbClick = jest.fn()\n\n    const { findByText } = render(\n      <AppLike client={createMockClient({})}>\n        <MobileBreadcrumb\n          breakpoints={{ isMobile: true }}\n          path={path}\n          onBreadcrumbClick={onBreadcrumbClick}\n          t={jest.fn()}\n        />\n      </AppLike>\n    )\n\n    // renders the path\n    const rootLink = await findByText('root folder')\n    await findByText('parent folder')\n    await findByText('current folder')\n\n    fireEvent.click(rootLink)\n    expect(onBreadcrumbClick).toHaveBeenCalledWith({\n      id: '1',\n      name: 'root folder'\n    })\n\n    const backButton = document.querySelector('button')\n    fireEvent.click(backButton)\n    expect(onBreadcrumbClick).toHaveBeenCalledWith({\n      id: '2',\n      name: 'parent folder'\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/breadcrumb/components/__snapshots__/Breadcrumb.spec.jsx.snap",
    "content": "// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing\n\nexports[`Breadcrumbs template should match snapshot 1`] = `\n<div>\n  <div\n    class=\"fil-path-backdrop\"\n  >\n    <h2\n      class=\"fil-path-title\"\n      data-testid=\"path-title\"\n    >\n      <span\n        class=\"fil-path-link\"\n      >\n        <span\n          class=\"fil-path-link-name\"\n        >\n          Drive\n        </span>\n        <svg\n          class=\"fil-path-separator styles__icon___23x3R\"\n          height=\"16\"\n          viewBox=\"0 0 16 16\"\n          width=\"16\"\n        >\n          <path\n            d=\"M8.6 8L4.7 4.1a.948.948 0 01-.275-.7c0-.284.092-.517.275-.7a.948.948 0 01.7-.275c.283 0 .517.091.7.275l4.6 4.6c.1.1.17.208.213.325.041.116.062.241.062.375 0 .133-.02.258-.063.375a.878.878 0 01-.212.325l-4.6 4.6a.948.948 0 01-.7.275.948.948 0 01-.7-.275.948.948 0 01-.275-.7c0-.284.092-.517.275-.7L8.6 8z\"\n          />\n        </svg>\n      </span>\n      <span\n        class=\"fil-path-link\"\n      >\n        <span\n          class=\"fil-path-link-name\"\n        >\n          grandParent\n        </span>\n        <svg\n          class=\"fil-path-separator styles__icon___23x3R\"\n          height=\"16\"\n          viewBox=\"0 0 16 16\"\n          width=\"16\"\n        >\n          <path\n            d=\"M8.6 8L4.7 4.1a.948.948 0 01-.275-.7c0-.284.092-.517.275-.7a.948.948 0 01.7-.275c.283 0 .517.091.7.275l4.6 4.6c.1.1.17.208.213.325.041.116.062.241.062.375 0 .133-.02.258-.063.375a.878.878 0 01-.212.325l-4.6 4.6a.948.948 0 01-.7.275.948.948 0 01-.7-.275.948.948 0 01-.275-.7c0-.284.092-.517.275-.7L8.6 8z\"\n          />\n        </svg>\n      </span>\n      <span\n        class=\"fil-path-link\"\n      >\n        <span\n          class=\"fil-path-link-name\"\n        >\n          parent\n        </span>\n        <svg\n          class=\"fil-path-separator styles__icon___23x3R\"\n          height=\"16\"\n          viewBox=\"0 0 16 16\"\n          width=\"16\"\n        >\n          <path\n            d=\"M8.6 8L4.7 4.1a.948.948 0 01-.275-.7c0-.284.092-.517.275-.7a.948.948 0 01.7-.275c.283 0 .517.091.7.275l4.6 4.6c.1.1.17.208.213.325.041.116.062.241.062.375 0 .133-.02.258-.063.375a.878.878 0 01-.212.325l-4.6 4.6a.948.948 0 01-.7.275.948.948 0 01-.7-.275.948.948 0 01-.275-.7c0-.284.092-.517.275-.7L8.6 8z\"\n          />\n        </svg>\n      </span>\n      <span\n        class=\"fil-path-current\"\n      >\n        <span\n          class=\"fil-path-current-name\"\n        >\n          current\n        </span>\n        <span\n          class=\"fil-path-down\"\n        />\n      </span>\n    </h2>\n  </div>\n</div>\n`;\n"
  },
  {
    "path": "src/modules/breadcrumb/hooks/useBreadcrumbPath.jsx",
    "content": "import { useEffect, useState } from 'react'\n\nimport { useClient } from 'cozy-client'\nimport log from 'cozy-logger'\n\nimport { SHARED_DRIVES_DIR_ID } from '@/constants/config'\nimport { fetchFolder, useFolder } from '@/modules/breadcrumb/utils/fetchFolder'\n\n/**\n * @typedef {Object} BreadcrumbPath\n * @property {string} name - The name of the folder.\n * @property {string} id - The ID of the folder.\n */\n\n/**\n * Custom hook that retrieves the breadcrumb path for a given folder.\n *\n * @param {Object} options - The options for retrieving the breadcrumb path.\n * @param {string} options.currentFolderId - The ID of the current folder.\n * @param {BreadcrumbPath} options.rootBreadcrumbPath - The root breadcrumb path object.\n * @param {string[]} [options.sharedDocumentIds] - The IDs of shared documents.\n * @returns {BreadcrumbPath[]} - The breadcrumb path as an array of objects.\n */\nexport const useBreadcrumbPath = ({\n  currentFolderId,\n  rootBreadcrumbPath,\n  sharedDocumentIds,\n  driveId\n}) => {\n  const client = useClient()\n  const [paths, setPaths] = useState([])\n\n  const folder = useFolder({ folderId: currentFolderId, driveId })\n  const folderAttributes = {\n    id: folder?.id,\n    name: folder?.name,\n    dirId: folder?.dir_id\n  }\n\n  useEffect(() => {\n    if (rootBreadcrumbPath && currentFolderId === rootBreadcrumbPath.id) {\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setPaths([rootBreadcrumbPath])\n      return\n    }\n\n    if (!folderAttributes.id || !folderAttributes.name) {\n      // Optionally set loading state or clear paths\n      setPaths([])\n      return\n    }\n\n    const hasAccessToSharedDocument = id => {\n      if (!sharedDocumentIds) return true\n      return !sharedDocumentIds.includes(id)\n    }\n\n    let isSubscribed = true\n    const returnedPaths = [\n      { name: folderAttributes.name, id: folderAttributes.id }\n    ]\n\n    const shouldContinueLoop = id => {\n      return (\n        !!id && id !== rootBreadcrumbPath?.id && id !== SHARED_DRIVES_DIR_ID\n      )\n    }\n\n    const processFolder = async id => {\n      const folder = await fetchFolder({ client, driveId, folderId: id })\n      if (!folder) return undefined\n\n      returnedPaths.unshift({ name: folder.name, id: folder.id })\n      return hasAccessToSharedDocument(folder.id) ? folder.dir_id : undefined\n    }\n\n    const shouldAddRootPath = () => {\n      return (\n        rootBreadcrumbPath?.name !== 'Public' &&\n        returnedPaths[0]?.id !== rootBreadcrumbPath.id\n      )\n    }\n\n    const handleBreadcrumbError = error => {\n      if (rootBreadcrumbPath?.name === 'Public') {\n        if (isSubscribed) {\n          setPaths(returnedPaths)\n        }\n      } else {\n        if (isSubscribed && rootBreadcrumbPath) {\n          setPaths([rootBreadcrumbPath])\n        }\n        log(\n          'error',\n          `Error while fetching folder for breadcrumbs of folder id: ${folderAttributes.id}, here is the error: ${error}`\n        )\n      }\n    }\n\n    const fetchBreadcrumbs = async () => {\n      let id = folderAttributes.dirId\n      while (shouldContinueLoop(id)) {\n        id = await processFolder(id)\n      }\n\n      if (isSubscribed) {\n        if (shouldAddRootPath()) {\n          returnedPaths.unshift(rootBreadcrumbPath)\n        }\n        setPaths(returnedPaths)\n      }\n    }\n\n    fetchBreadcrumbs().catch(handleBreadcrumbError)\n\n    return () => {\n      isSubscribed = false\n    }\n  }, [\n    client,\n    sharedDocumentIds,\n    rootBreadcrumbPath,\n    driveId,\n    folderAttributes.id,\n    folderAttributes.name,\n    folderAttributes.dirId,\n    currentFolderId\n  ])\n\n  return paths\n}\n"
  },
  {
    "path": "src/modules/breadcrumb/hooks/useBreadcrumbPath.spec.jsx",
    "content": "import { act, renderHook } from '@testing-library/react'\n\nimport { useClient } from 'cozy-client'\nimport log from 'cozy-logger'\n\nimport { useBreadcrumbPath } from './useBreadcrumbPath'\nimport {\n  dummyBreadcrumbPathWithRootLarge,\n  dummyRootBreadcrumbPath\n} from 'test/dummies/dummyBreadcrumbPath'\n\nimport { fetchFolder, useFolder } from '@/modules/breadcrumb/utils/fetchFolder'\n\njest.mock('cozy-logger')\njest.mock('cozy-client')\njest.mock('modules/breadcrumb/utils/fetchFolder')\n\ndescribe('useBreadcrumbPath', () => {\n  const rootBreadcrumbPath = dummyRootBreadcrumbPath()\n  const createFolder = ({ id, name, dirId }) => ({\n    id,\n    name,\n    dir_id: dirId\n  })\n\n  beforeEach(() => {\n    jest.resetAllMocks()\n    useFolder.mockReturnValue(null)\n  })\n\n  it('should get useClient from cozy-client', () => {\n    // When\n    renderHook(() => useBreadcrumbPath({}))\n\n    // Then\n    expect(useClient).toHaveBeenCalledWith()\n  })\n\n  it('should return only Drive link when id undefined', async () => {\n    useFolder.mockReturnValue(\n      createFolder({\n        id: rootBreadcrumbPath.id,\n        name: rootBreadcrumbPath.name,\n        dirId: undefined\n      })\n    )\n    // When\n    const { result } = await renderHook(() =>\n      useBreadcrumbPath({ rootBreadcrumbPath })\n    )\n\n    // Then\n    expect(result.current).toEqual([rootBreadcrumbPath])\n  })\n\n  it('should return only Drive link when id is root_breadcrumb_path id', async () => {\n    useFolder.mockReturnValue(\n      createFolder({\n        id: rootBreadcrumbPath.id,\n        name: rootBreadcrumbPath.name,\n        dirId: undefined\n      })\n    )\n    // When\n    let render\n    await act(async () => {\n      render = await renderHook(() =>\n        useBreadcrumbPath({\n          rootBreadcrumbPath,\n          currentFolderId: rootBreadcrumbPath.id\n        })\n      )\n    })\n\n    // Then\n    expect(render.result.current).toEqual([dummyRootBreadcrumbPath()])\n  })\n\n  it('should return only rootBreadcrumbPath when currentFolderId equals rootBreadcrumbPath.id (early return)', async () => {\n    const someFolderId = 'some-folder-id'\n    useFolder.mockReturnValue(\n      createFolder({\n        id: someFolderId,\n        name: 'Some Folder',\n        dirId: rootBreadcrumbPath.id\n      })\n    )\n\n    let render\n    await act(async () => {\n      render = await renderHook(() =>\n        useBreadcrumbPath({\n          rootBreadcrumbPath,\n          currentFolderId: rootBreadcrumbPath.id\n        })\n      )\n    })\n\n    expect(render.result.current).toEqual([rootBreadcrumbPath])\n    expect(fetchFolder).not.toHaveBeenCalled()\n  })\n\n  it('should call fetch folder', async () => {\n    // Given\n    const currentFolderId = '1234'\n    useClient.mockReturnValue('cozy-client')\n    const parentFolderId = 'parentFolderId'\n    useFolder.mockReturnValue(\n      createFolder({\n        id: currentFolderId,\n        name: 'current',\n        dirId: parentFolderId\n      })\n    )\n    fetchFolder.mockReturnValueOnce({ dir_id: rootBreadcrumbPath.id })\n\n    // When\n    await act(async () => {\n      await renderHook(() =>\n        useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId })\n      )\n    })\n\n    // Then\n    expect(fetchFolder).toHaveBeenCalledWith({\n      client: 'cozy-client',\n      folderId: parentFolderId\n    })\n  })\n\n  it('should log error when fetchFolder rejects error', async () => {\n    // Given\n    const currentFolderId = '1234'\n    useClient.mockReturnValue('cozy-client')\n    fetchFolder.mockRejectedValue('error')\n    const parentFolderId = 'parentFolderId'\n    useFolder.mockReturnValue(\n      createFolder({\n        id: currentFolderId,\n        name: 'current',\n        dirId: parentFolderId\n      })\n    )\n\n    // When\n    let render\n    await act(async () => {\n      render = await renderHook(() =>\n        useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId })\n      )\n    })\n\n    // Then\n    expect(render.result.current).toEqual([rootBreadcrumbPath])\n    expect(log).toHaveBeenCalledWith(\n      'error',\n      'Error while fetching folder for breadcrumbs of folder id: 1234, here is the error: error'\n    )\n  })\n\n  it('should not loop when fetchFolder returns undefined', async () => {\n    // Given\n    const currentFolderId = '1234'\n    useClient.mockReturnValue('cozy-client')\n    fetchFolder.mockReturnValueOnce(undefined)\n    const parentFolderId = 'parentFolderId'\n    useFolder.mockReturnValue(\n      createFolder({\n        id: currentFolderId,\n        name: 'current',\n        dirId: parentFolderId\n      })\n    )\n\n    // When\n    await act(async () => {\n      await renderHook(() =>\n        useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId })\n      )\n    })\n\n    // Then\n    expect(fetchFolder).toHaveBeenCalledTimes(1)\n  })\n\n  it('should fetch several folder until rootBreadcrumbPath.id', async () => {\n    // Given\n    const currentFolderId = 'currentFolderId'\n    const parentFolderId = 'parentFolderId'\n    const grandParentFolderId = 'grandParentFolderId'\n    useClient.mockReturnValue('cozy-client')\n    useFolder.mockReturnValue(\n      createFolder({\n        id: currentFolderId,\n        name: 'current',\n        dirId: parentFolderId\n      })\n    )\n    fetchFolder.mockReturnValueOnce({\n      id: parentFolderId,\n      name: 'parent',\n      dir_id: grandParentFolderId\n    })\n    fetchFolder.mockReturnValueOnce({\n      id: grandParentFolderId,\n      name: 'grandParent',\n      dir_id: rootBreadcrumbPath.id\n    })\n\n    // When\n    let render\n    await act(async () => {\n      render = await renderHook(() =>\n        useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId })\n      )\n    })\n\n    // Then\n    expect(fetchFolder).toHaveBeenCalledTimes(2)\n    expect(fetchFolder).toHaveBeenCalledWith({\n      client: 'cozy-client',\n      folderId: parentFolderId\n    })\n    expect(fetchFolder).toHaveBeenNthCalledWith(2, {\n      client: 'cozy-client',\n      folderId: grandParentFolderId\n    })\n    expect(render.result.current).toEqual(dummyBreadcrumbPathWithRootLarge())\n  })\n\n  it('should not call fetch folder, on rerender', async () => {\n    // Given\n    const currentFolderId = '1234'\n    useClient.mockReturnValue('cozy-client')\n    fetchFolder.mockReturnValueOnce({ dir_id: rootBreadcrumbPath.id })\n    const parentFolderId = 'parentFolderId'\n    useFolder.mockReturnValue(\n      createFolder({\n        id: currentFolderId,\n        name: 'current',\n        dirId: parentFolderId\n      })\n    )\n\n    // When\n    let render\n    await act(async () => {\n      render = await renderHook(() =>\n        useBreadcrumbPath({ rootBreadcrumbPath, currentFolderId })\n      )\n    })\n    expect(fetchFolder).toHaveBeenCalledTimes(1)\n\n    render.rerender()\n\n    // Then\n    expect(fetchFolder).toHaveBeenCalledTimes(1)\n  })\n\n  it('should not add rootBreadcrumbPath when name undefined on PublicView', async () => {\n    // Given\n    const publicViewRootBreadcrumbPath = {\n      id: rootBreadcrumbPath.id,\n      name: 'Public'\n    }\n    const currentFolderId = 'currentFolderId'\n    useClient.mockReturnValue('cozy-client')\n    useFolder.mockReturnValue(\n      createFolder({\n        id: currentFolderId,\n        name: 'current',\n        dirId: publicViewRootBreadcrumbPath.id\n      })\n    )\n\n    // When\n    let render\n    await act(async () => {\n      render = await renderHook(() =>\n        useBreadcrumbPath({\n          rootBreadcrumbPath: publicViewRootBreadcrumbPath,\n          currentFolderId\n        })\n      )\n    })\n\n    // Then\n    expect(render.result.current).toEqual([\n      { id: 'currentFolderId', name: 'current' }\n    ])\n  })\n\n  it('should fetch folder until first shared documents on SharingView', async () => {\n    // Given\n    const currentFolderId = 'currentFolderId'\n    const parentFolderId = 'parentFolderId'\n    const notSharedFolderId = 'notSharedFolderId'\n    const sharedDocumentIds = [parentFolderId, 'another-id']\n    useClient.mockReturnValue('cozy-client')\n    useFolder.mockReturnValue(\n      createFolder({\n        id: currentFolderId,\n        name: 'current',\n        dirId: parentFolderId\n      })\n    )\n    fetchFolder.mockReturnValueOnce({\n      id: parentFolderId,\n      name: 'parent',\n      dir_id: notSharedFolderId\n    })\n    const sharingsViewRootBreadcrumbPath = {\n      id: rootBreadcrumbPath.id,\n      name: 'Sharings'\n    }\n\n    // When\n    let render\n    await act(async () => {\n      render = await renderHook(() =>\n        useBreadcrumbPath({\n          rootBreadcrumbPath: sharingsViewRootBreadcrumbPath,\n          currentFolderId,\n          sharedDocumentIds\n        })\n      )\n    })\n\n    // Then\n    expect(render.result.current).toEqual([\n      sharingsViewRootBreadcrumbPath,\n      { id: 'parentFolderId', name: 'parent' },\n      { id: 'currentFolderId', name: 'current' }\n    ])\n    expect(fetchFolder).toHaveBeenCalledTimes(1)\n    expect(fetchFolder).toHaveBeenCalledWith({\n      client: 'cozy-client',\n      folderId: parentFolderId\n    })\n  })\n\n  it('should stop at the first shared document even when current is shared', async () => {\n    // Given\n    const currentFolderId = 'currentFolderId'\n    const parentFolderId = 'parentFolderId'\n    const notSharedFolderId = 'notSharedFolderId'\n    const sharedDocumentIds = [parentFolderId, currentFolderId, 'another-id']\n    useClient.mockReturnValue('cozy-client')\n    useFolder.mockReturnValue(\n      createFolder({\n        id: currentFolderId,\n        name: 'current',\n        dirId: parentFolderId\n      })\n    )\n    fetchFolder.mockReturnValueOnce({\n      id: parentFolderId,\n      name: 'parent',\n      dir_id: notSharedFolderId\n    })\n    const sharingsViewRootBreadcrumbPath = {\n      id: rootBreadcrumbPath.id,\n      name: 'Sharings'\n    }\n\n    // When\n    let render\n    await act(async () => {\n      render = await renderHook(() =>\n        useBreadcrumbPath({\n          rootBreadcrumbPath: sharingsViewRootBreadcrumbPath,\n          currentFolderId,\n          sharedDocumentIds\n        })\n      )\n    })\n\n    // Then\n    expect(render.result.current).toEqual([\n      sharingsViewRootBreadcrumbPath,\n      { id: 'parentFolderId', name: 'parent' },\n      { id: 'currentFolderId', name: 'current' }\n    ])\n    expect(fetchFolder).toHaveBeenCalledTimes(1)\n    expect(fetchFolder).toHaveBeenCalledWith({\n      client: 'cozy-client',\n      folderId: parentFolderId\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/breadcrumb/styles/breadcrumb.styl",
    "content": "@require 'components/popover.styl'\n@require 'settings/breakpoints.styl'\n@require 'settings/z-index.styl'\n@require '../../../styles/coz-bar-size.styl'\n\n.fil-path-backdrop\n    flex 1 1 auto\n    width 1%\n    min-width 0\n\n    &:not([override])\n        margin-right 2rem\n\n    ol\n        flex-wrap nowrap\n        min-width 0\n        overflow hidden\n        text-overflow ellipsis\n\n        li\n            &:last-child\n                min-width 0\n\n.fil-path-title\n    margin    0\n    font-size 1.5rem\n    overflow hidden\n    white-space nowrap\n    text-overflow ellipsis\n    display block\n\n.fil-path-link\n    display          inline-flex\n    align-items      baseline\n    font-weight      normal\n    color            var(--actionColorActive)\n    text-decoration  none\n    cursor           pointer\n\n    .fil-path-separator\n        margin 0 .25rem\n\n    &:hover\n        color          var(--primaryTextColor)\n\n.fil-path-down\n    display     none\n    min-width   .875rem\n    height      .625rem\n    margin-left .4375rem\n    border      0\n    background  embedurl('../../../assets/icons/icon-arrow-down.svg') center center no-repeat\n\n.fil-path-current-name\n    text-overflow  ellipsis\n    overflow       hidden\n    white-space    nowrap\n    color          var(--primaryTextColor)\n    font-weight    bold\n\n+small-screen() // @stylint ignore\n    .fil-path-backdrop\n        min-width 0\n        width auto\n\n    .fil-path-title\n        display        flex\n        flex-direction column-reverse\n        font-size      1.3rem\n\n    .fil-path-link\n    .fil-path-current\n        display       flex\n        align-items   center\n        box-sizing    border-box\n        height        $coz-bar-size\n        padding       0 .25rem\n\n    .fil-path-down\n        display inline-block\n\n    .fil-path-link\n    .fil-path-separator\n        display none\n\n    .fil-path-backdrop.deployed\n        margin   0\n        position fixed\n        top      0\n        right    0\n        bottom     0\n        left       0\n        z-index $overlay-index\n\n        &.inlined\n            position absolute\n\n        .fil-path-title\n            z-index    $popover-index\n            box-shadow 0 .0625rem 0 0 var(--actionColorDisabled), 0 .375rem 1.5rem 0 rgba(50, 54, 63, .24)\n\n        .fil-path-link\n        .fil-path-current\n            padding-left 'calc(%s + .25rem)' % $coz-bar-size\n\n        .fil-path-link\n            display flex\n            color   var(--primaryTextColor)\n            background var(--paperBackgroundColor)\n\n        .fil-path-link-name\n            text-overflow  ellipsis\n            overflow       hidden\n            white-space    nowrap\n\n        .fil-path-current\n            height     $coz-bar-size\n            box-shadow inset 0 -.0625rem 0 0 var(--actionColorDisabled)\n            padding-right 2.35rem\n\n    .fil-path-backdrop.mobile\n        left 0\n"
  },
  {
    "path": "src/modules/breadcrumb/utils/fetchFolder.js",
    "content": "import { useQuery } from 'cozy-client'\n\nimport {\n  buildFileOrFolderByIdQuery,\n  buildSharedDriveFolderQuery\n} from '@/queries'\n\nexport const fetchFolder = async ({ client, folderId, driveId }) => {\n  const folderQuery = driveId\n    ? buildSharedDriveFolderQuery({ driveId, folderId })\n    : buildFileOrFolderByIdQuery(folderId)\n  const { options, definition } = folderQuery\n  const folderQueryResults = await client.fetchQueryAndGetFromState({\n    definition: definition(),\n    options\n  })\n  return folderQueryResults.data\n}\n\n/**\n * Hook to fetch a folder from cozy stack\n *\n * @param {Object} params - The parameters for the function.\n * @param {string} params.folderId - The ID of the folder to fetch.\n * @param {string} [params.driveId] - The ID of the shared drive to fetch the folder from.\n * @returns {import('cozy-client/types/types').IOCozyFolder} The folder data.\n */\nexport const useFolder = ({ folderId, driveId }) => {\n  const folderQuery = driveId\n    ? buildSharedDriveFolderQuery({ driveId, folderId })\n    : buildFileOrFolderByIdQuery(folderId)\n  const { options, definition } = folderQuery\n  const folderQueryResults = useQuery(definition, options)\n  return folderQueryResults.data\n}\n"
  },
  {
    "path": "src/modules/breadcrumb/utils/fetchFolder.spec.js",
    "content": "import { fetchFolder } from './fetchFolder'\n\nimport { buildFileOrFolderByIdQuery } from '@/queries'\n\njest.mock('queries')\n\ndescribe('fetchFolder', () => {\n  const folderReturnedByCozyClient = 'folder'\n  const result = { data: folderReturnedByCozyClient }\n  const client = {\n    fetchQueryAndGetFromState: jest.fn().mockReturnValue(result)\n  }\n  const folderId = '1234'\n  const definition = jest.fn().mockReturnValue('definition')\n\n  beforeEach(() => {\n    buildFileOrFolderByIdQuery.mockReturnValue({\n      definition: definition,\n      options: 'options'\n    })\n  })\n\n  it('should return answer from fetchQueryAndGetFromState', async () => {\n    // When\n    const folder = await fetchFolder({ client, folderId })\n\n    // Then\n    expect(folder).toEqual(folderReturnedByCozyClient)\n  })\n\n  it('should call fetchQueryAndGetFromState with correct definition and options', async () => {\n    // When\n    await fetchFolder({ client, folderId })\n\n    // Then\n    expect(definition).toHaveBeenCalledWith()\n    expect(client.fetchQueryAndGetFromState).toHaveBeenCalledWith({\n      definition: 'definition',\n      options: 'options'\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/certifications/CertificationTooltip.jsx",
    "content": "import React from 'react'\n\nimport Tooltip from 'cozy-ui/transpiled/react/Tooltip'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\n\nconst CertificationTooltip = ({ body, caption, content }) => {\n  return (\n    <Tooltip\n      title={\n        <>\n          <Typography variant=\"body1\" color=\"inherit\">\n            {body}\n          </Typography>\n          <Typography variant=\"caption\" color=\"inherit\">\n            {caption}\n          </Typography>\n        </>\n      }\n    >\n      <span className=\"u-w-100\">{content}</span>\n    </Tooltip>\n  )\n}\n\nexport default CertificationTooltip\n"
  },
  {
    "path": "src/modules/certifications/index.jsx",
    "content": "import PropTypes from 'prop-types'\n\nimport {\n  CarbonCopy as CarbonCopyCell,\n  ElectronicSafe as ElectronicSafeCell\n} from '@/modules/filelist/cells'\nimport {\n  CarbonCopy as CarbonCopyHeader,\n  ElectronicSafe as ElectronicSafeHeader\n} from '@/modules/filelist/headers'\n\nexport const extraColumnsSpecs = {\n  carbonCopy: {\n    query: ({ queryBuilder, currentFolderId, sharedDocumentIds, attribute }) =>\n      queryBuilder({ currentFolderId, sharedDocumentIds, attribute }),\n    condition: ({ conditionBuilder, files, attribute }) =>\n      conditionBuilder({ files, attribute }),\n    label: 'carbonCopy',\n    HeaderComponent: CarbonCopyHeader,\n    CellComponent: CarbonCopyCell\n  },\n  electronicSafe: {\n    query: ({ queryBuilder, currentFolderId, sharedDocumentIds, attribute }) =>\n      queryBuilder({ currentFolderId, sharedDocumentIds, attribute }),\n    condition: ({ conditionBuilder, files, attribute }) =>\n      conditionBuilder({ files, attribute }),\n    label: 'electronicSafe',\n    HeaderComponent: ElectronicSafeHeader,\n    CellComponent: ElectronicSafeCell\n  }\n}\n\nconst extraColumnPropTypes = PropTypes.shape({\n  query: PropTypes.func,\n  condition: PropTypes.func,\n  label: PropTypes.string,\n  HeaderComponent: PropTypes.func,\n  CellComponent: PropTypes.func\n})\n\nexport const extraColumnsPropTypes = PropTypes.arrayOf(extraColumnPropTypes)\n\n/**\n * Returns the columns names according to the media\n * @param {object} params - Params\n * @param {boolean} params.isMobile - Whether the breakpoint is mobile\n * @param {string[]} params.mobileExtraColumnsNames - Names of the columns to be shown in mobile\n * @param {string[]} params.desktopExtraColumnsNames - Names of the columns to be shown in desktop\n * @returns {string[]} Names of the columns\n */\nexport const makeExtraColumnsNamesFromMedia = ({\n  isMobile,\n  mobileExtraColumnsNames,\n  desktopExtraColumnsNames\n}) => (isMobile ? mobileExtraColumnsNames : desktopExtraColumnsNames)\n"
  },
  {
    "path": "src/modules/certifications/useExtraColumns.jsx",
    "content": "import { useEffect, useMemo } from 'react'\n\nimport { useClient } from 'cozy-client'\n\nimport { extraColumnsSpecs } from '@/modules/certifications/'\n\n/**\n * @typedef {object} ExtraColumn\n * @property {function} query - The query function.\n * @property {function} condition - The condition function.\n * @property {string} label - The label of the column.\n * @property {function} HeaderComponent - The header component.\n * @property {function} CellComponent - The cell component.\n */\n\n// TODO: some ways to improve:\n// instead of passing currentFolderId, sharedDocumentIds (related to the query)\n// and files (related to the condition), maybe we could pass\n// the query/condition with its parameters\n\n/**\n * Custom hook that adds extra columns to a table based on the provided configuration.\n *\n * @param {object} options - The options for configuring the extra columns.\n * @param {string[]} [options.columnsNames] - The names of the columns to add.\n * @param {function} [options.queryBuilder] - The query builder for fetching data.\n * @param {function} [options.conditionBuilder] - The condition builder for filtering data.\n * @param {string} [options.currentFolderId] - The ID of the current folder.\n * @param {string[]} [options.sharedDocumentIds] - The IDs of the shared documents.\n * @param {object[]} [options.files] - The files to display in the table.\n * @returns {object[]} - The extra columns to add to the table.\n */\nexport const useExtraColumns = ({\n  columnsNames,\n  queryBuilder,\n  conditionBuilder,\n  currentFolderId,\n  sharedDocumentIds,\n  files\n}) => {\n  const client = useClient()\n  const columnsSpecs = useMemo(\n    () => columnsNames.map(columnName => extraColumnsSpecs[columnName]),\n    [columnsNames]\n  )\n\n  useEffect(() => {\n    if (!queryBuilder) {\n      return\n    }\n    for (let columnSpec of columnsSpecs) {\n      if (!columnSpec.query) {\n        continue\n      }\n      const opts = {\n        queryBuilder,\n        currentFolderId,\n        sharedDocumentIds,\n        attribute: columnSpec.label\n      }\n      const def = columnSpec.query(opts).definition()\n      client.query(def, columnSpec.query(opts).options)\n    }\n  }, [client, columnsSpecs, currentFolderId, sharedDocumentIds, queryBuilder])\n\n  return columnsSpecs.filter(columnSpec => {\n    if (conditionBuilder) {\n      const opts = {\n        conditionBuilder,\n        files,\n        attribute: columnSpec.label\n      }\n      return columnSpec.condition(opts)\n    } else if (queryBuilder) {\n      const opts = {\n        queryBuilder,\n        currentFolderId,\n        sharedDocumentIds,\n        attribute: columnSpec.label\n      }\n      const { fetchStatus, data } = client.getQueryFromState(\n        columnSpec.query(opts).options.as\n      )\n      return fetchStatus === 'loaded' && data.length > 0\n    } else {\n      throw new Error(\n        'useExtraColumns must have queryBuilder or conditionBuilder'\n      )\n    }\n  })\n}\n"
  },
  {
    "path": "src/modules/certifications/useExtraColumns.spec.jsx",
    "content": "import { renderHook } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient, models } from 'cozy-client'\n\nimport { useExtraColumns } from './useExtraColumns'\nimport AppLike from 'test/components/AppLike'\n\nconst client = createMockClient({})\nclient.query = jest.fn()\n\nconst setup = ({ columnsNames, queryBuilder, conditionBuilder, files }) => {\n  const wrapper = ({ children }) => (\n    <AppLike client={client}>{children}</AppLike>\n  )\n\n  return renderHook(\n    () =>\n      useExtraColumns({\n        columnsNames,\n        queryBuilder,\n        conditionBuilder,\n        currentFolderId: '123',\n        sharedDocumentIds: '456',\n        files\n      }),\n    {\n      wrapper\n    }\n  )\n}\n\ndescribe('useExtraColumns', () => {\n  it('should return error if no queryBuilder or conditionBuilder passed', () => {\n    jest.spyOn(console, 'error').mockImplementation()\n    expect(() => setup({ columnsNames: ['carbonCopy'] })).toThrow(\n      'useExtraColumns must have queryBuilder or conditionBuilder'\n    )\n  })\n})\n\ndescribe('useExtraColumns : queryBuilder', () => {\n  it('should not query anything if no queryBuilder passed', () => {\n    jest.spyOn(console, 'error').mockImplementation()\n    expect(() => setup({ columnsNames: ['carbonCopy'] })).toThrow()\n    expect(client.query).not.toHaveBeenCalled()\n  })\n\n  it('should execute query if queryBuilder passed', async () => {\n    setup({\n      columnsNames: ['carbonCopy'],\n      queryBuilder: () => ({\n        definition: () => 'queryDefinition',\n        options: 'queryOptions'\n      })\n    })\n\n    expect(client.query).toHaveBeenCalled()\n  })\n\n  it('should return carbonCopy column if the query result returns at least one file', async () => {\n    // mock returned value for query checking if at least one file as carbonCopy metadata\n    client.getQueryFromState = jest.fn(() => ({\n      fetchStatus: 'loaded',\n      data: [{ id: '01', metadata: { carbonCopy: true } }]\n    }))\n\n    const { result } = setup({\n      columnsNames: ['carbonCopy'],\n      queryBuilder: () => ({\n        definition: () => 'queryDefinition',\n        options: 'queryOptions'\n      })\n    })\n\n    expect(\n      result.current.some(extraColumn => extraColumn.label === 'carbonCopy')\n    ).toBeTruthy()\n  })\n})\n\ndescribe('useExtraColumns : conditionBuilder', () => {\n  const conditionBuilder = ({ files, attribute }) =>\n    files.some(file => models.file.hasMetadataAttribute({ file, attribute }))\n\n  it('should return empty array if no files', async () => {\n    const { result } = setup({\n      columnsNames: ['carbonCopy'],\n      conditionBuilder,\n      files: []\n    })\n\n    expect(result.current).toMatchObject([])\n  })\n\n  it('should return empty array if no columns names', async () => {\n    const { result } = setup({\n      columnsNames: [],\n      conditionBuilder,\n      files: [{ id: '01' }]\n    })\n\n    expect(result.current).toMatchObject([])\n  })\n\n  it('should return empty array if no files with matching metadata', async () => {\n    const { result } = setup({\n      columnsNames: ['carbonCopy'],\n      conditionBuilder,\n      files: [{ id: '01' }]\n    })\n\n    expect(result.current).toMatchObject([])\n  })\n\n  it('should return carbonCopy column if at least one file has carbonCopy metadata', async () => {\n    const { result } = setup({\n      columnsNames: ['carbonCopy'],\n      conditionBuilder,\n      files: [{ id: '01', metadata: { carbonCopy: true } }]\n    })\n\n    expect(\n      result.current.some(extraColumn => extraColumn.label === 'carbonCopy')\n    ).toBeTruthy()\n  })\n\n  it('should not return carbonCopy column if this column is not wanted, even if a file has carbonCopy metadata', async () => {\n    const { result } = setup({\n      columnsNames: ['electronicSafe'],\n      conditionBuilder,\n      files: [{ id: '01', metadata: { carbonCopy: true } }]\n    })\n\n    expect(result.current).toMatchObject([])\n  })\n})\n"
  },
  {
    "path": "src/modules/drive/AddMenu/AddMenu.jsx",
    "content": "import React from 'react'\n\nimport ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'\n\nimport AddMenuContent from '@/modules/drive/AddMenu/AddMenuContent'\n\nconst AddMenu = ({\n  anchorRef,\n  handleClose,\n  isUploadDisabled,\n  canCreateFolder,\n  canUpload,\n  refreshFolderContent,\n  isPublic,\n  displayedFolder,\n  isReadOnly,\n  ...actionMenuProps\n}) => {\n  return (\n    <ActionsMenu\n      open\n      ref={anchorRef}\n      onClose={handleClose}\n      docs={[displayedFolder]}\n      actions={[]}\n      {...actionMenuProps}\n    >\n      <AddMenuContent\n        isUploadDisabled={isUploadDisabled}\n        canCreateFolder={canCreateFolder}\n        canUpload={canUpload}\n        refreshFolderContent={refreshFolderContent}\n        isPublic={isPublic}\n        displayedFolder={displayedFolder}\n        onClick={handleClose}\n        isReadOnly={isReadOnly}\n      />\n    </ActionsMenu>\n  )\n}\n\nexport default AddMenu\n"
  },
  {
    "path": "src/modules/drive/AddMenu/AddMenuContent.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport flag from 'cozy-flags'\nimport ActionsMenuMobileHeader from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuMobileHeader'\nimport Divider from 'cozy-ui/transpiled/react/Divider'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport AddFolderItem from '@/modules/drive/Toolbar/components/AddFolderItem'\nimport CreateDocsItem from '@/modules/drive/Toolbar/components/CreateDocsItem'\nimport CreateNoteItem from '@/modules/drive/Toolbar/components/CreateNoteItem'\nimport CreateOnlyOfficeItem from '@/modules/drive/Toolbar/components/CreateOnlyOfficeItem'\nimport CreateShortcut from '@/modules/drive/Toolbar/components/CreateShortcut'\nimport { ScannerMenuItem } from '@/modules/drive/Toolbar/components/Scanner/ScannerMenuItem'\nimport { useScannerContext } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider'\nimport UploadItem from '@/modules/drive/Toolbar/components/UploadItem'\nimport { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'\nimport { NewItemHighlightProvider } from '@/modules/upload/NewItemHighlightProvider'\nimport { isOfficeEditingEnabled } from '@/modules/views/OnlyOffice/helpers'\n\nconst AddMenuContent = forwardRef(\n  (\n    {\n      isUploadDisabled,\n      canCreateFolder,\n      canUpload,\n      refreshFolderContent,\n      isPublic,\n      displayedFolder,\n      onClick,\n      isReadOnly\n    },\n    ref // eslint-disable-line no-unused-vars\n  ) => {\n    const { t } = useI18n()\n    const { isDesktop } = useBreakpoints()\n    const { hasScanner } = useScannerContext()\n    const { showAlert } = useAlert()\n\n    const handleReadOnlyClick = e => {\n      e.stopPropagation()\n      e.preventDefault()\n      showAlert(\n        t(\n          'AddMenu.readOnlyFolder',\n          'This is a read-only folder. You cannot perform this action.'\n        ),\n        'warning'\n      )\n      onClick()\n    }\n\n    const createActionOnClick = isReadOnly ? handleReadOnlyClick : onClick\n\n    return (\n      <>\n        <ActionsMenuMobileHeader>\n          <ListItemText\n            primary={t('toolbar.menu_create')}\n            primaryTypographyProps={{ variant: 'h6' }}\n          />\n        </ActionsMenuMobileHeader>\n\n        {canCreateFolder && (\n          <AddFolderItem onClick={onClick} isReadOnly={isReadOnly} />\n        )}\n        {!isPublic && (\n          <CreateNoteItem\n            displayedFolder={displayedFolder}\n            isReadOnly={isReadOnly}\n            onClick={onClick}\n          />\n        )}\n        {!isPublic && flag('drive.lasuitedocs.enabled') && (\n          <CreateDocsItem\n            displayedFolder={displayedFolder}\n            isReadOnly={isReadOnly}\n            onClick={onClick}\n          />\n        )}\n        {canUpload && isOfficeEditingEnabled(isDesktop) && (\n          <>\n            <CreateOnlyOfficeItem\n              fileClass=\"text\"\n              isReadOnly={isReadOnly}\n              onClick={onClick}\n            />\n            <CreateOnlyOfficeItem\n              fileClass=\"spreadsheet\"\n              isReadOnly={isReadOnly}\n              onClick={onClick}\n            />\n            <CreateOnlyOfficeItem\n              fileClass=\"slide\"\n              isReadOnly={isReadOnly}\n              onClick={onClick}\n            />\n          </>\n        )}\n        {!isFromSharedDriveRecipient(displayedFolder) && (\n          <CreateShortcut\n            onCreated={refreshFolderContent}\n            onClick={onClick}\n            isReadOnly={isReadOnly}\n          />\n        )}\n        {canUpload && !isUploadDisabled && (\n          <NewItemHighlightProvider>\n            <Divider className=\"u-mv-half\" />\n            <UploadItem\n              onUploaded={refreshFolderContent}\n              displayedFolder={displayedFolder}\n              onClick={onClick}\n              isReadOnly={isReadOnly}\n            />\n          </NewItemHighlightProvider>\n        )}\n        {hasScanner && <ScannerMenuItem onClick={createActionOnClick} />}\n      </>\n    )\n  }\n)\n\nAddMenuContent.displayName = 'AddMenuContent'\n\nexport default AddMenuContent\n"
  },
  {
    "path": "src/modules/drive/AddMenu/AddMenuContent.spec.jsx",
    "content": "import { render, waitFor } from '@testing-library/react'\nimport React from 'react'\n\nimport { useAppLinkWithStoreFallback } from 'cozy-client'\n\nimport AddMenuContent from './AddMenuContent'\nimport AppLike from 'test/components/AppLike'\nimport { setupFolderContent, mockCozyClientRequestQuery } from 'test/setup'\n\nimport { ScannerProvider } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider'\n\njest.mock('cozy-client/dist/hooks/useAppLinkWithStoreFallback', () => jest.fn())\njest.mock('cozy-keys-lib', () => ({\n  useVaultClient: jest.fn()\n}))\nmockCozyClientRequestQuery()\n\nconst setup = async (\n  { folderId = 'directory-foobar0' } = {},\n  {\n    isUploadDisabled = false,\n    canCreateFolder = false,\n    canUpload = true,\n    refreshFolderContent = true,\n    isPublic = false,\n    isReadOnly = false\n  } = {}\n) => {\n  const { client, store } = await setupFolderContent({\n    folderId\n  })\n\n  const displayedFolder = folderId ? { id: folderId } : folderId\n\n  client.stackClient.uri = 'http://cozy.localhost'\n\n  const root = render(\n    <AppLike client={client} store={store}>\n      <ScannerProvider displayedFolder={displayedFolder}>\n        <AddMenuContent\n          isUploadDisabled={isUploadDisabled}\n          canCreateFolder={canCreateFolder}\n          canUpload={canUpload}\n          refreshFolderContent={refreshFolderContent}\n          isPublic={isPublic}\n          displayedFolder={displayedFolder}\n          onClick={() => {}}\n          isReadOnly={isReadOnly}\n        />\n      </ScannerProvider>\n    </AppLike>\n  )\n  return { root }\n}\n\ndescribe('AddMenuContent', () => {\n  describe('Menu', () => {\n    beforeAll(() => {\n      useAppLinkWithStoreFallback.mockReturnValue({\n        fetchStatus: 'loaded',\n        isInstalled: true\n      })\n    })\n\n    it('does not display createNote on public Page', async () => {\n      await waitFor(async () => {\n        const { root } = await setup(\n          { folderId: 'directory-foobar0' },\n          { isPublic: true }\n        )\n        const { queryByText } = root\n        expect(queryByText('Note')).toBeNull()\n      })\n    })\n\n    it('displays createNote on private Page', async () => {\n      await waitFor(async () => {\n        const { root } = await setup(\n          { folderId: 'directory-foobar0' },\n          { isPublic: false }\n        )\n        const { queryByText } = root\n        expect(queryByText('Note')).toBeTruthy()\n      })\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/drive/AddMenu/AddMenuProvider.jsx",
    "content": "import React, {\n  useState,\n  useCallback,\n  useRef,\n  useMemo,\n  createContext\n} from 'react'\n\nimport useBrowserOffline from 'cozy-ui/transpiled/react/hooks/useBrowserOffline'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport logger from '@/lib/logger'\nimport AddMenu from '@/modules/drive/AddMenu/AddMenu'\nimport {\n  closeMenu,\n  toggleMenu\n} from '@/modules/drive/Toolbar/components/MoreMenu'\nimport { ScannerProvider } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider'\n\nexport const AddMenuContext = createContext()\n\nconst AddMenuProvider = ({\n  disabled,\n  canCreateFolder,\n  canUpload,\n  refreshFolderContent,\n  children,\n  isPublic,\n  displayedFolder,\n  isSelectionBarVisible,\n  componentsProps,\n  isReadOnly\n}) => {\n  const [menuIsVisible, setMenuVisible] = useState(false)\n  const isOffline = useBrowserOffline()\n  const anchorRef = useRef()\n  const { showAlert } = useAlert()\n  const { t } = useI18n()\n\n  const handleClose = useCallback(\n    () => closeMenu(setMenuVisible),\n    [setMenuVisible]\n  )\n\n  const handleToggle = useCallback(\n    () => toggleMenu(menuIsVisible, setMenuVisible),\n    [menuIsVisible, setMenuVisible]\n  )\n\n  const isDisabled = useMemo(\n    () => disabled || isSelectionBarVisible,\n    [disabled, isSelectionBarVisible]\n  )\n\n  const handleOfflineClick = useCallback(\n    e => {\n      e.stopPropagation()\n      showAlert({ message: t('alert.offline'), severity: 'error' })\n      logger.error(\n        `Offline click on AddMenu button detected. Here is the value of window.navigator.onLine: ${window.navigator.onLine}`\n      )\n    },\n    [showAlert, t]\n  )\n\n  return (\n    <AddMenuContext.Provider\n      value={{\n        anchorRef,\n        handleToggle,\n        isDisabled,\n        isOffline,\n        handleOfflineClick,\n        isPublic,\n        a11y: {\n          'aria-controls': menuIsVisible ? 'add-menu' : undefined,\n          'aria-haspopup': true,\n          'aria-expanded': menuIsVisible ? true : undefined\n        }\n      }}\n    >\n      {children}\n      <ScannerProvider displayedFolder={displayedFolder}>\n        {menuIsVisible && (\n          <AddMenu\n            id=\"add-menu\"\n            anchorRef={anchorRef}\n            handleClose={handleClose}\n            canCreateFolder={canCreateFolder}\n            canUpload={canUpload}\n            refreshFolderContent={refreshFolderContent}\n            isPublic={isPublic}\n            displayedFolder={displayedFolder}\n            isReadOnly={isReadOnly}\n            {...componentsProps?.AddMenu}\n          />\n        )}\n      </ScannerProvider>\n    </AddMenuContext.Provider>\n  )\n}\n\nexport default React.memo(AddMenuProvider)\n"
  },
  {
    "path": "src/modules/drive/AddMenu/AddMenuProvider.spec.jsx",
    "content": "import { fireEvent, render } from '@testing-library/react'\nimport React, { useContext } from 'react'\n\nimport { createMockClient } from 'cozy-client'\n\nimport AddMenuProvider, { AddMenuContext } from './AddMenuProvider'\nimport AppLike from 'test/components/AppLike'\n\nimport logger from '@/lib/logger'\n\njest.mock('react-redux', () => ({\n  ...jest.requireActual('react-redux'),\n  useSelector: jest.fn()\n}))\njest.mock('lib/logger', () => ({\n  error: jest.fn()\n}))\n\nconst client = createMockClient({})\n\ndescribe('AddMenuContext', () => {\n  it('should log exception on click offline on add button', () => {\n    // Given\n    const Component = () => {\n      const { handleOfflineClick } = useContext(AddMenuContext)\n      return <button data-testid=\"button\" onClick={handleOfflineClick} />\n    }\n    const { container, getByTestId } = render(\n      <AppLike client={client}>\n        <AddMenuProvider>\n          <Component />\n        </AddMenuProvider>\n      </AppLike>\n    )\n    // When\n    fireEvent.click(getByTestId('button'))\n    fireEvent.click(container)\n\n    // Then\n    expect(logger.error).toHaveBeenCalledWith(\n      'Offline click on AddMenu button detected. Here is the value of window.navigator.onLine: true'\n    )\n  })\n})\n"
  },
  {
    "path": "src/modules/drive/DeleteConfirm.jsx",
    "content": "import React, { useCallback, useEffect, useState } from 'react'\n\nimport { useClient } from 'cozy-client'\nimport { splitFilename } from 'cozy-client/dist/models/file'\nimport { SharedDocument, SharedRecipientsList } from 'cozy-sharing'\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport Stack from 'cozy-ui/transpiled/react/Stack'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { useSelectionContext } from '../selection/SelectionProvider'\n\nimport { DOCTYPE_ALBUMS } from '@/lib/doctypes'\nimport { getEntriesTypeTranslated } from '@/lib/entries'\nimport { trashFiles } from '@/modules/actions/utils'\nimport { buildAlbumByIdQuery } from '@/queries'\n\nconst Message = ({ type, fileCount }) => {\n  const icon =\n    type === 'referenced' ? 'album' : type.includes('share') ? 'people' : type\n\n  const { t } = useI18n()\n  return (\n    <div className=\"u-flex u-flex-items-center\">\n      <Icon\n        icon={icon}\n        className=\"u-flex-shrink-0\"\n        color=\"var(--iconTextColor)\"\n      />\n      <Typography className=\"u-pl-1-half\">\n        {t(`DeleteConfirm.${type}`, fileCount)}\n      </Typography>\n    </div>\n  )\n}\n\nexport const DeleteConfirm = ({\n  files,\n  afterConfirmation,\n  onClose,\n  children,\n  driveId\n}) => {\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n  const fileCount = files.length\n  const client = useClient()\n  const [isDeleting, setDeleting] = useState(false)\n  const [isReferencedByManualAlbum, setIsReferencedByManualAlbum] =\n    useState(false)\n  const { setSelectedItems } = useSelectionContext()\n\n  useEffect(() => {\n    const fetchAlbums = async () => {\n      const albumIdsFromFiles = files.flatMap(file =>\n        (\n          (file &&\n            file.relationships &&\n            file.relationships.referenced_by &&\n            file.relationships.referenced_by.data) ||\n          []\n        )\n          .filter(reference => reference.type === DOCTYPE_ALBUMS)\n          .map(reference => reference.id)\n      )\n\n      const albums = await Promise.all(\n        albumIdsFromFiles.map(albumId => {\n          const albumByIdQuery = buildAlbumByIdQuery(albumId)\n          return client.fetchQueryAndGetFromState({\n            definition: albumByIdQuery.definition(),\n            options: albumByIdQuery.options\n          })\n        })\n      )\n\n      setIsReferencedByManualAlbum(\n        !!albums.filter(album => album && album.data && !album.data.auto).length\n      )\n    }\n    fetchAlbums()\n  }, [client, files])\n\n  const onDelete = useCallback(async () => {\n    // Prevent double executions\n    if (isDeleting) return\n\n    setDeleting(true)\n    showAlert({ message: t('alert.trash_file_processing'), severity: 'info' })\n    onClose()\n    await trashFiles(client, files, { showAlert, t, driveId })\n    afterConfirmation()\n    setSelectedItems(prevSelectedItems => {\n      const fileIdsToRemove = files.map(file => file.id)\n      return Object.fromEntries(\n        Object.entries(prevSelectedItems).filter(\n          ([id]) => !fileIdsToRemove.includes(id)\n        )\n      )\n    })\n  }, [\n    client,\n    files,\n    afterConfirmation,\n    onClose,\n    showAlert,\n    t,\n    setSelectedItems,\n    driveId,\n    isDeleting\n  ])\n\n  const entriesType = getEntriesTypeTranslated(t, files)\n\n  return (\n    <ConfirmDialog\n      open={true}\n      onClose={onClose}\n      title={\n        <Typography variant=\"h3\" className=\"u-ellipsis\">\n          {t('DeleteConfirm.title', {\n            filename: splitFilename(files[0]).filename,\n            smart_count: fileCount,\n            type: entriesType\n          })}\n        </Typography>\n      }\n      content={\n        <Stack>\n          <Message type=\"trash\" fileCount={fileCount} />\n          <Message type=\"restore\" fileCount={fileCount} />\n          {isReferencedByManualAlbum && (\n            <Message type=\"referenced\" fileCount={fileCount} />\n          )}\n          {children}\n        </Stack>\n      }\n      actions={\n        <>\n          <Button\n            variant=\"secondary\"\n            onClick={onClose}\n            label={t('DeleteConfirm.cancel')}\n          />\n          <Button\n            variant=\"primary\"\n            onClick={onDelete}\n            label={t('DeleteConfirm.delete')}\n            color=\"error\"\n            busy={isDeleting}\n          />\n        </>\n      }\n    />\n  )\n}\n\nconst DeleteConfirmWithSharingContext = ({ files, ...rest }) =>\n  files.length !== 1 ? (\n    <DeleteConfirm files={files} {...rest} />\n  ) : (\n    <SharedDocument docId={files[0].id}>\n      {({ isSharedByMe, link, recipients }) => {\n        const statuses = recipients\n          .map(recipient => recipient.status)\n          .filter(status => status !== 'owner')\n\n        const isStatusesEqual = statuses.reduce((acc, current) => {\n          return acc && current === statuses[0]\n        }, true)\n\n        let shareMessageType = !isStatusesEqual\n          ? 'share_both'\n          : statuses[0] === 'ready'\n            ? 'share_accepted'\n            : 'share_waiting'\n\n        return (\n          <DeleteConfirm files={files} {...rest}>\n            {isSharedByMe && link ? (\n              <Message type=\"link\" fileCount={files.length} />\n            ) : null}\n            {isSharedByMe && statuses.length > 0 ? (\n              <Message type={shareMessageType} fileCount={files.length} />\n            ) : null}\n            {isSharedByMe && recipients.length > 0 ? (\n              <SharedRecipientsList\n                className=\"u-ml-2-half\"\n                docId={files[0].id}\n              />\n            ) : null}\n          </DeleteConfirm>\n        )\n      }}\n    </SharedDocument>\n  )\n\nexport default DeleteConfirmWithSharingContext\n"
  },
  {
    "path": "src/modules/drive/DeleteConfirm.spec.jsx",
    "content": "import { render, fireEvent, waitFor } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\n\nimport { DeleteConfirm } from './DeleteConfirm'\nimport AppLike from 'test/components/AppLike'\nimport { generateFile } from 'test/generate'\n\nimport { trashFiles } from '@/modules/actions/utils'\n\nconst setSelectedItems = jest.fn()\n\njest.mock('modules/selection/SelectionProvider', () => ({\n  ...jest.requireActual('modules/selection/SelectionProvider'),\n  useSelectionContext: () => ({\n    setSelectedItems\n  })\n}))\n\njest.mock('modules/actions/utils', () => ({\n  trashFiles: jest.fn().mockResolvedValue({})\n}))\n\ndescribe('DeleteConfirm', () => {\n  const setup = files => {\n    const client = createMockClient({})\n    const afterConfirmation = jest.fn()\n    const onClose = jest.fn()\n\n    const renderResult = render(\n      <AppLike client={client}>\n        <DeleteConfirm\n          files={files}\n          afterConfirmation={afterConfirmation}\n          onClose={onClose}\n        />\n      </AppLike>\n    )\n\n    return { client, afterConfirmation, onClose, ...renderResult }\n  }\n\n  it('tests the component', async () => {\n    const files = [generateFile({ i: '10', type: 'file' })]\n    const { client, afterConfirmation, onClose, getByText } = setup(files)\n\n    expect(getByText('Delete foobar10?')).toBeTruthy()\n\n    const confirmButton = getByText('Remove')\n    fireEvent.click(confirmButton)\n\n    expect(trashFiles).toHaveBeenCalledWith(\n      client,\n      files,\n      expect.objectContaining({})\n    )\n\n    waitFor(() => {\n      expect(afterConfirmation).toHaveBeenCalled()\n      expect(setSelectedItems).toHaveLength(0)\n      expect(onClose).toHaveBeenCalled()\n    })\n  })\n\n  it('removes only the deletes file from selection', async () => {\n    const files = Array.from({ length: 10 }, (_, i) =>\n      generateFile({ i: i + 1, type: 'file' })\n    )\n    const selectedItems = {\n      [files[0].id]: files[0],\n      [files[1].id]: files[1],\n      [files[2].id]: files[2]\n    }\n    const fileToDelete = [files[4]]\n    const { client, afterConfirmation, onClose, getByText } =\n      setup(fileToDelete)\n\n    expect(getByText('Delete foobar5?')).toBeTruthy()\n    fireEvent.click(getByText('Remove'))\n\n    expect(trashFiles).toHaveBeenCalledWith(\n      client,\n      fileToDelete,\n      expect.objectContaining({})\n    )\n\n    waitFor(() => {\n      expect(afterConfirmation).toHaveBeenCalled()\n      expect(onClose).toHaveBeenCalled()\n      expect(setSelectedItems).toHaveBeenCalledWith(expect.any(Function))\n\n      const updateFn = setSelectedItems.mock.calls[0][0]\n      const result = updateFn(selectedItems)\n\n      expect(result).toEqual(selectedItems)\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/drive/FabWithAddMenuContext.jsx",
    "content": "import React, { useContext } from 'react'\n\nimport { ExtendableFab } from 'cozy-ui/transpiled/react/Fab'\nimport PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus'\nimport { useI18n } from 'twake-i18n'\n\nimport { AddMenuContext } from '@/modules/drive/AddMenu/AddMenuProvider'\nimport { useFabStyles } from '@/modules/drive/helpers'\n\nconst FabWithAddMenuContext = ({ noSidebar }) => {\n  const { t } = useI18n()\n\n  const {\n    anchorRef,\n    handleToggle,\n    isDisabled,\n    handleOfflineClick,\n    isOffline,\n    a11y\n  } = useContext(AddMenuContext)\n\n  const styles = useFabStyles({\n    bottom: noSidebar ? '1rem' : 'calc(var(--sidebarHeight) + 2rem)'\n  })\n\n  return (\n    <div onClick={isOffline ? handleOfflineClick : undefined}>\n      <ExtendableFab\n        ref={anchorRef ? anchorRef : undefined}\n        color=\"primary\"\n        label={t('button.create')}\n        icon={PlusIcon}\n        className={styles.root}\n        disabled={isDisabled || isOffline}\n        follow={window}\n        onClick={handleToggle}\n        {...a11y}\n      />\n    </div>\n  )\n}\n\nexport default React.memo(FabWithAddMenuContext)\n"
  },
  {
    "path": "src/modules/drive/RenameInput.jsx",
    "content": "import React from 'react'\nimport { connect } from 'react-redux'\n\nimport { useClient } from 'cozy-client'\nimport useBrowserOffline from 'cozy-ui/transpiled/react/hooks/useBrowserOffline'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { abortRenaming } from './rename'\n\nimport { CozyFile } from '@/models'\nimport FilenameInput from '@/modules/filelist/FilenameInput'\n\n// If we set the _rev then CozyClient tries to update. Else\n// it tries to create\nexport const updateFileNameQuery = async (client, file, newName) => {\n  return client.collection('io.cozy.files', { driveId: file.driveId }).update({\n    ...file,\n    name: newName,\n    _rev: file._rev || file.meta.rev\n  })\n}\n\nexport const RenameInput = ({\n  onAbort,\n  file,\n  refreshFolderContent,\n  className,\n  style,\n  withoutExtension\n}) => {\n  const client = useClient()\n  const { showAlert } = useAlert()\n  const { t } = useI18n()\n\n  const { filename, extension } = CozyFile.splitFilename(file)\n  const name = withoutExtension ? filename : file.name\n  const isOffline = useBrowserOffline()\n\n  return (\n    <FilenameInput\n      className={className}\n      style={style}\n      name={name}\n      file={file}\n      onSubmit={async newValue => {\n        const newName = withoutExtension ? newValue + extension : newValue\n        try {\n          if (isOffline) {\n            showAlert({ message: t('alert.offline'), severity: 'error' })\n          } else {\n            await updateFileNameQuery(client, file, newName)\n            if (refreshFolderContent) refreshFolderContent()\n          }\n        } catch (error) {\n          if (\n            error.message.includes(\n              'NetworkError when attempting to fetch resource.'\n            )\n          ) {\n            showAlert({ message: t('upload.alert.network'), severity: 'error' })\n          } else if (\n            error.message.includes(\n              'Invalid filename containing illegal character(s):'\n            )\n          ) {\n            showAlert({\n              message: t('alert.file_name_illegal_characters', {\n                fileName: newName,\n                characters: error.message.split(\n                  'Invalid filename containing illegal character(s): '\n                )[1]\n              }),\n              severity: 'error',\n              duration: 2000\n            })\n          } else if (error.message.includes('Invalid filename:')) {\n            showAlert({\n              message: t('alert.file_name_illegal_name', { fileName: newName }),\n              severity: 'error'\n            })\n          } else if (error.message.includes('Missing name argument')) {\n            showAlert({\n              message: t('alert.file_name_missing'),\n              severity: 'error'\n            })\n          } else {\n            showAlert({\n              message: t('alert.file_name', { fileName: newName }),\n              severity: 'error'\n            })\n          }\n        } finally {\n          onAbort()\n        }\n      }}\n      onAbort={onAbort}\n    />\n  )\n}\n\nconst mapDispatchToProps = dispatch => ({\n  onAbort: () => dispatch(abortRenaming())\n})\n\nexport default connect(null, mapDispatchToProps)(RenameInput)\n"
  },
  {
    "path": "src/modules/drive/RenameInput.spec.jsx",
    "content": "import { render, fireEvent, screen, waitFor, act } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\nimport useBrowserOffline from 'cozy-ui/transpiled/react/hooks/useBrowserOffline'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\n\nimport { RenameInput } from './RenameInput'\nimport AppLike from 'test/components/AppLike'\nimport { generateFile } from 'test/generate'\n\nconst showAlert = jest.fn()\n\njest.mock('cozy-ui/transpiled/react/hooks/useBrowserOffline')\njest.mock('cozy-ui/transpiled/react/providers/Alert', () => ({\n  ...jest.requireActual('cozy-ui/transpiled/react/providers/Alert'),\n  __esModule: true,\n  useAlert: jest.fn()\n}))\ndescribe('RenameInput', () => {\n  let client\n  let onAbort\n  let file\n  let mockCollection\n  let mockSharingsCollection\n\n  beforeEach(() => {\n    jest.resetAllMocks()\n    mockCollection = {\n      update: jest.fn()\n    }\n    mockSharingsCollection = {\n      renameSharedDrive: jest.fn()\n    }\n    client = {\n      ...createMockClient({}),\n      collection: jest\n        .fn()\n        .mockImplementation(name =>\n          name === 'io.cozy.sharings' ? mockSharingsCollection : mockCollection\n        )\n    }\n    onAbort = jest.fn()\n    // Default file without driveId for backward compatibility\n    file = {\n      ...generateFile({ i: '10', type: 'file' }),\n      meta: { rev: '1' },\n      _id: 'file123',\n      _type: 'io.cozy.files'\n      // No driveId by default for backward compatibility\n    }\n    useAlert.mockReturnValue({ showAlert })\n  })\n\n  const setup = ({ file }) => {\n    return render(\n      <AppLike client={client}>\n        <RenameInput file={file} onAbort={onAbort} />\n      </AppLike>\n    )\n  }\n\n  it('tests the component', async () => {\n    const { getByText } = setup({ file })\n    const inputNode = document.getElementsByTagName('input')[0]\n\n    fireEvent.change(inputNode, { target: { value: 'new Name.pdf' } })\n    expect(inputNode.value).toBe('new Name.pdf')\n    fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })\n    // For backward compatibility, don't expect driveId in the collection call\n    expect(client.collection).toHaveBeenCalledWith('io.cozy.files', {})\n    expect(mockCollection.update).toHaveBeenCalledWith(\n      expect.objectContaining({\n        name: 'new Name.pdf',\n        _rev: '1'\n      })\n    )\n    await waitFor(() => expect(onAbort).toHaveBeenCalled())\n\n    // Check the Modal to inform that we're changing the file extension\n    fireEvent.change(inputNode, { target: { value: 'new Name.txt' } })\n    expect(inputNode.value).toBe('new Name.txt')\n    fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })\n    await waitFor(() => screen.getByRole('dialog'))\n\n    fireEvent.click(getByText('Continue'))\n    // For backward compatibility, don't expect driveId in the collection call\n    expect(client.collection).toHaveBeenCalledWith('io.cozy.files', {})\n    expect(mockCollection.update).toHaveBeenCalledWith(\n      expect.objectContaining({\n        name: 'new Name.txt',\n        _rev: '1'\n      })\n    )\n    await waitFor(() => expect(onAbort).toHaveBeenCalled())\n  })\n\n  it('works without meta rev', async () => {\n    // Test with file that doesn't have meta.rev but has _rev\n    const fileWithoutMetaRev = {\n      ...file,\n      _rev: '2',\n      meta: {}\n    }\n    setup({ file: fileWithoutMetaRev })\n    const inputNode = document.getElementsByTagName('input')[0]\n\n    await act(async () => {\n      fireEvent.change(inputNode, { target: { value: 'new Name.pdf' } })\n      fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })\n    })\n    // For backward compatibility, don't expect driveId in the collection call\n    expect(client.collection).toHaveBeenCalledWith('io.cozy.files', {})\n    expect(mockCollection.update).toHaveBeenCalledWith(\n      expect.objectContaining({\n        name: 'new Name.pdf',\n        _rev: '2'\n      })\n    )\n  })\n\n  it('works with driveId', async () => {\n    // Test with explicit driveId\n    const fileWithDriveId = {\n      ...file,\n      driveId: 'special-drive-123',\n      _rev: '3',\n      meta: { rev: '3' }\n    }\n    setup({ file: fileWithDriveId })\n    const inputNode = document.getElementsByTagName('input')[0]\n\n    await act(async () => {\n      fireEvent.change(inputNode, { target: { value: 'drive-file.pdf' } })\n      fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })\n    })\n\n    // Should include the driveId in the collection options\n    expect(client.collection).toHaveBeenCalledWith('io.cozy.files', {\n      driveId: 'special-drive-123'\n    })\n\n    // Should include the file in the update with the correct _rev\n    expect(mockCollection.update).toHaveBeenCalledWith(\n      expect.objectContaining({\n        name: 'drive-file.pdf',\n        _rev: '3',\n        driveId: 'special-drive-123'\n      })\n    )\n  })\n\n  it('should alert error on illegal characters', async () => {\n    setup({ file })\n    const inputNode = document.getElementsByTagName('input')[0]\n\n    mockCollection.update.mockRejectedValueOnce({\n      message: 'Invalid filename containing illegal character(s): /'\n    })\n\n    fireEvent.change(inputNode, { target: { value: 'new/Name.pdf' } })\n    fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })\n\n    await waitFor(() => {\n      expect(showAlert).toHaveBeenCalledTimes(1)\n      expect(showAlert).toHaveBeenCalledWith(\n        expect.objectContaining({ severity: 'error', duration: 2000 })\n      )\n    })\n  })\n\n  it('should alert error on incorrect file name', async () => {\n    setup({ file })\n    const inputNode = document.getElementsByTagName('input')[0]\n\n    mockCollection.update.mockRejectedValueOnce({\n      message: 'Invalid filename: .'\n    })\n\n    fireEvent.change(inputNode, { target: { value: '..pdf' } })\n    fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })\n\n    await waitFor(() => {\n      expect(showAlert).toHaveBeenCalledTimes(1)\n      expect(showAlert).toHaveBeenCalledWith(\n        expect.objectContaining({ severity: 'error' })\n      )\n    })\n  })\n\n  it('should alert error on missing file name', async () => {\n    setup({ file })\n    const inputNode = document.getElementsByTagName('input')[0]\n\n    mockCollection.update.mockRejectedValueOnce({\n      message: 'Missing name argument'\n    })\n\n    fireEvent.change(inputNode, { target: { value: '   .pdf' } })\n    fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })\n\n    await waitFor(() => {\n      expect(showAlert).toHaveBeenCalledTimes(1)\n      expect(showAlert).toHaveBeenCalledWith(\n        expect.objectContaining({ severity: 'error' })\n      )\n    })\n  })\n\n  it('should alert network error when detected by useBrowserOffline', async () => {\n    useBrowserOffline.mockReturnValue(true)\n    setup({ file })\n    const inputNode = document.getElementsByTagName('input')[0]\n\n    fireEvent.change(inputNode, { target: { value: '   .pdf' } })\n    fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })\n\n    await waitFor(() => {\n      expect(showAlert).toHaveBeenCalledTimes(1)\n      expect(showAlert).toHaveBeenCalledWith(\n        expect.objectContaining({ severity: 'error' })\n      )\n    })\n  })\n\n  it('should alert network error when not detected by useBrowserOffline', async () => {\n    setup({ file })\n    const inputNode = document.getElementsByTagName('input')[0]\n\n    mockCollection.update.mockRejectedValueOnce({\n      message: 'NetworkError when attempting to fetch resource.'\n    })\n\n    fireEvent.change(inputNode, { target: { value: '   .pdf' } })\n    fireEvent.keyDown(inputNode, { key: 'Enter', code: 'Enter', keyCode: 13 })\n\n    await waitFor(() => {\n      expect(showAlert).toHaveBeenCalledTimes(1)\n      expect(showAlert).toHaveBeenCalledWith(\n        expect.objectContaining({ severity: 'error' })\n      )\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/AddButton.jsx",
    "content": "import React, { useContext } from 'react'\n\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus'\nimport { useI18n } from 'twake-i18n'\n\nimport { AddMenuContext } from '@/modules/drive/AddMenu/AddMenuProvider'\n\nexport const AddButton = ({ className }) => {\n  const { t } = useI18n()\n  const {\n    anchorRef,\n    handleToggle,\n    isDisabled,\n    handleOfflineClick,\n    isOffline,\n    a11y\n  } = useContext(AddMenuContext)\n\n  return (\n    <div ref={anchorRef} onClick={isOffline ? handleOfflineClick : undefined}>\n      <Button\n        className={className}\n        variant=\"primary\"\n        disabled={isDisabled || isOffline}\n        startIcon={<Icon icon={PlusIcon} size={12} />}\n        label={t('toolbar.menu_create')}\n        onClick={handleToggle}\n        {...a11y}\n      />\n    </div>\n  )\n}\n\nexport default React.memo(AddButton)\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/AddFolderItem.jsx",
    "content": "import React from 'react'\nimport { connect } from 'react-redux'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconFolder from 'cozy-ui/transpiled/react/Icons/FileTypeFolder'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { showNewFolderInput } from '@/modules/filelist/duck'\n\nconst AddFolderItem = ({ addFolder, onClick, isReadOnly }) => {\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n\n  const handleClick = () => {\n    if (isReadOnly) {\n      showAlert({\n        message: t(\n          'AddMenu.readOnlyFolder',\n          'This is a read-only folder. You cannot perform this action.'\n        ),\n        severity: 'warning'\n      })\n      onClick()\n      return\n    }\n    addFolder()\n    onClick()\n  }\n\n  return (\n    <ActionsMenuItem data-testid=\"add-folder-link\" onClick={handleClick}>\n      <ListItemIcon>\n        <Icon icon={IconFolder} />\n      </ListItemIcon>\n      <ListItemText primary={t('toolbar.menu_new_folder')} />\n    </ActionsMenuItem>\n  )\n}\n\nconst mapDispatchToProps = dispatch => ({\n  addFolder: () =>\n    setTimeout(() => {\n      dispatch(showNewFolderInput())\n    }, 0)\n})\n\nexport default connect(null, mapDispatchToProps)(AddFolderItem)\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/AddMenuItem.jsx",
    "content": "import React, { useContext } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useI18n } from 'twake-i18n'\n\nimport { AddMenuContext } from '@/modules/drive/AddMenu/AddMenuProvider'\n\nconst AddMenuItem = ({ onClick }) => {\n  const { t } = useI18n()\n\n  const {\n    anchorRef,\n    handleToggle,\n    isDisabled,\n    handleOfflineClick,\n    isOffline,\n    a11y\n  } = useContext(AddMenuContext)\n\n  const handleClick = () => {\n    isOffline ? handleOfflineClick() : handleToggle()\n    onClick()\n  }\n\n  return (\n    <ActionsMenuItem\n      ref={anchorRef}\n      disabled={isDisabled || isOffline}\n      onClick={handleClick}\n      {...a11y}\n    >\n      <ListItemIcon>\n        <Icon icon={<Icon icon={PlusIcon} />} />\n      </ListItemIcon>\n      <ListItemText primary={t('toolbar.menu_add_item')} />\n    </ActionsMenuItem>\n  )\n}\n\nexport default AddMenuItem\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/CreateDocsItem.jsx",
    "content": "import get from 'lodash/get'\nimport React from 'react'\n\nimport { useClient, generateWebLink, useCapabilities } from 'cozy-client'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport IconDocs from '@/assets/icons/icon-docs.svg'\nimport { displayedFolderOrRootFolder } from '@/hooks/helpers'\n\nconst CreateDocsItem = ({ displayedFolder, isReadOnly, onClick }) => {\n  const client = useClient()\n  const { t } = useI18n()\n\n  const { capabilities } = useCapabilities(client)\n  const isFlatDomain = get(capabilities, 'flat_subdomains')\n  const { showAlert } = useAlert()\n\n  const _displayedFolder = displayedFolderOrRootFolder(displayedFolder)\n\n  const handleClick = async () => {\n    if (isReadOnly) {\n      showAlert({\n        message: t(\n          'AddMenu.readOnlyFolder',\n          'This is a read-only folder. You cannot perform this action.'\n        ),\n        severity: 'warning'\n      })\n      onClick()\n      return\n    }\n\n    const url = generateWebLink({\n      slug: 'docs',\n      cozyUrl: client.getStackClient().uri,\n      subDomainType: isFlatDomain ? 'flat' : 'nested',\n      pathname: '',\n      hash: `/bridge/docs/new/${_displayedFolder._id}`\n    })\n\n    window.location.href = url\n  }\n\n  return (\n    <ActionsMenuItem onClick={handleClick}>\n      <ListItemIcon>\n        <Icon icon={IconDocs} />\n      </ListItemIcon>\n      <ListItemText primary={t('toolbar.menu_create_docs')} />\n    </ActionsMenuItem>\n  )\n}\n\nexport default CreateDocsItem\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/CreateNoteItem.jsx",
    "content": "import get from 'lodash/get'\nimport React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport {\n  withClient,\n  generateWebLink,\n  models,\n  useAppLinkWithStoreFallback,\n  useCapabilities\n} from 'cozy-client'\nimport { isFlagshipApp } from 'cozy-device-helper'\nimport flag from 'cozy-flags'\nimport { useWebviewIntent } from 'cozy-intent'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconNote from 'cozy-ui/transpiled/react/Icons/FileTypeNote'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { generateUniversalLink } from 'cozy-ui-plus/dist/AppLinker/native'\nimport { translate } from 'twake-i18n'\n\nimport { displayedFolderOrRootFolder } from '@/hooks/helpers'\n\nconst CreateNoteItem = ({\n  client,\n  t,\n  displayedFolder,\n  isReadOnly,\n  onClick\n}) => {\n  const { capabilities } = useCapabilities(client)\n  const isFlatDomain = get(capabilities, 'flat_subdomains')\n  const webviewIntent = useWebviewIntent()\n  const { showAlert } = useAlert()\n  const navigate = useNavigate()\n\n  const _displayedFolder = displayedFolderOrRootFolder(displayedFolder)\n  const { driveId, id: folderId } = _displayedFolder\n\n  const { fetchStatus, url, isInstalled } = useAppLinkWithStoreFallback(\n    'notes',\n    client\n  )\n\n  if (fetchStatus !== 'loaded' || !isInstalled) {\n    return null\n  }\n\n  const notesAppUrl = url\n\n  let returnUrl = ''\n  if (\n    (isFlagshipApp() && webviewIntent) ||\n    flag('cozy.universal-link.disabled')\n  ) {\n    returnUrl = generateWebLink({\n      slug: 'drive',\n      cozyUrl: client.getStackClient().uri,\n      subDomainType: isFlatDomain ? 'flat' : 'nested',\n      pathname: '',\n      hash: `/files/${folderId}`\n    })\n  } else {\n    returnUrl = generateUniversalLink({\n      slug: 'drive',\n      cozyUrl: client.getStackClient().uri,\n      subDomainType: isFlatDomain ? 'flat' : 'nested',\n      nativePath: driveId\n        ? `/shareddrive/${driveId}/files/${folderId}`\n        : `/files/${folderId}`\n    })\n  }\n\n  const handleClick = async () => {\n    if (isReadOnly) {\n      showAlert({\n        message: t(\n          'AddMenu.readOnlyFolder',\n          'This is a read-only folder. You cannot perform this action.'\n        ),\n        severity: 'warning'\n      })\n      onClick()\n      return\n    }\n\n    if (notesAppUrl === undefined) return\n\n    const { data: file } = await client\n      .collection('io.cozy.notes', { driveId })\n      .create({\n        dir_id: folderId\n      })\n\n    if (driveId) {\n      navigate(`/note/${driveId}/${file.id}`)\n      return\n    }\n\n    const privateUrl = await models.note.generatePrivateUrl(notesAppUrl, file, {\n      returnUrl\n    })\n\n    /**\n     * Not using AppLinker here because it would require too much refactoring and would be risky\n     * Instead we use the webviewIntent programmatically to open the cozy-note app on the note href\n     */\n    if (isFlagshipApp() && webviewIntent)\n      return webviewIntent.call('openApp', privateUrl, { slug: 'notes' })\n\n    window.location.href = privateUrl\n  }\n\n  return (\n    <ActionsMenuItem data-testid=\"create-a-note\" onClick={handleClick}>\n      <ListItemIcon>\n        <Icon icon={IconNote} />\n      </ListItemIcon>\n      <ListItemText primary={t('toolbar.menu_create_note')} />\n    </ActionsMenuItem>\n  )\n}\n\nexport default translate()(withClient(CreateNoteItem))\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/CreateOnlyOfficeItem.jsx",
    "content": "import React, { useCallback, useMemo } from 'react'\nimport { useParams, useNavigate } from 'react-router-dom'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config'\nimport {\n  makeOnlyOfficeIconByClass,\n  canWriteOfficeDocument\n} from '@/modules/views/OnlyOffice/helpers'\n\nconst CreateOnlyOfficeItem = ({ fileClass, isReadOnly, onClick }) => {\n  const { folderId = ROOT_DIR_ID, driveId = undefined } = useParams()\n  const { t } = useI18n()\n  const navigate = useNavigate()\n  const { showAlert } = useAlert()\n\n  const _folderId = folderId === TRASH_DIR_ID ? ROOT_DIR_ID : folderId\n\n  const handleClick = useCallback(() => {\n    if (isReadOnly) {\n      showAlert({\n        message: t(\n          'AddMenu.readOnlyFolder',\n          'This is a read-only folder. You cannot perform this action.'\n        ),\n        severity: 'warning'\n      })\n      onClick()\n      return\n    }\n\n    if (canWriteOfficeDocument()) {\n      navigate(\n        driveId\n          ? `/onlyoffice/create/${driveId}/${_folderId}/${fileClass}`\n          : `/onlyoffice/create/${_folderId}/${fileClass}`\n      )\n    } else {\n      navigate(\n        driveId\n          ? `/onlyoffice/${driveId}/${_folderId}/paywall`\n          : `/folder/${_folderId}/paywall`\n      )\n    }\n  }, [\n    isReadOnly,\n    showAlert,\n    t,\n    onClick,\n    navigate,\n    driveId,\n    _folderId,\n    fileClass\n  ])\n\n  const ClassIcon = useMemo(\n    () => makeOnlyOfficeIconByClass(fileClass),\n    [fileClass]\n  )\n\n  return (\n    <ActionsMenuItem onClick={handleClick}>\n      <ListItemIcon>\n        <Icon icon={ClassIcon} />\n      </ListItemIcon>\n      <ListItemText primary={t(`toolbar.menu_onlyOffice.${fileClass}`)} />\n    </ActionsMenuItem>\n  )\n}\n\nexport default React.memo(CreateOnlyOfficeItem)\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/CreateShortcut.jsx",
    "content": "import React from 'react'\nimport { connect } from 'react-redux'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport DeviceBrowserIcon from 'cozy-ui/transpiled/react/Icons/DeviceBrowser'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport ShortcutCreationModal from './ShortcutCreationModal'\n\nimport { showModal } from '@/lib/react-cozy-helpers'\n\nconst CreateShortcutWrapper = ({ openModal, onClick, isReadOnly }) => {\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n\n  const handleClick = () => {\n    if (isReadOnly) {\n      showAlert({\n        message: t(\n          'AddMenu.readOnlyFolder',\n          'This is a read-only folder. You cannot perform this action.'\n        ),\n        severity: 'warning'\n      })\n      onClick()\n      return\n    }\n    openModal()\n    onClick()\n  }\n\n  return (\n    <ActionsMenuItem data-testid=\"create-a-shortcut\" onClick={handleClick}>\n      <ListItemIcon>\n        <Icon icon={DeviceBrowserIcon} />\n      </ListItemIcon>\n      <ListItemText primary={t('toolbar.menu_create_shortcut')} />\n    </ActionsMenuItem>\n  )\n}\n\nconst mapDispatchToProps = (dispatch, ownProps) => ({\n  openModal: () =>\n    dispatch(\n      showModal(<ShortcutCreationModal onCreated={ownProps.onCreated} />)\n    )\n})\n\nexport default connect(null, mapDispatchToProps)(CreateShortcutWrapper)\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/DownloadButtonItem.jsx",
    "content": "import React from 'react'\n\nimport { useClient } from 'cozy-client'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { downloadFiles } from '@/modules/actions/utils'\n\nconst DownloadButtonItem = ({ files }) => {\n  const { showAlert } = useAlert()\n  const { t } = useI18n()\n  const client = useClient()\n\n  const handleClick = () => {\n    downloadFiles(client, files, { showAlert, t })\n  }\n\n  return (\n    <ActionsMenuItem onClick={handleClick}>\n      <ListItemIcon>\n        <Icon icon={DownloadIcon} />\n      </ListItemIcon>\n      <ListItemText primary={t('toolbar.menu_download_folder')} />\n    </ActionsMenuItem>\n  )\n}\n\nexport default DownloadButtonItem\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/FavoritesItem.jsx",
    "content": "import React from 'react'\n\nimport { useClient } from 'cozy-client'\nimport { splitFilename } from 'cozy-client/dist/models/file'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport StarIcon from 'cozy-ui/transpiled/react/Icons/Star'\nimport StarOutlineIcon from 'cozy-ui/transpiled/react/Icons/StarOutline'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nconst FavoritesItem = ({ displayedFolder }) => {\n  const { showAlert } = useAlert()\n  const { t } = useI18n()\n  const client = useClient()\n\n  const isFavorite = displayedFolder?.cozyMetadata?.favorite\n  const labelKey = isFavorite ? 'remove' : 'add'\n\n  const handleClick = async () => {\n    if (!displayedFolder) return\n\n    try {\n      await client.save({\n        ...displayedFolder,\n        cozyMetadata: {\n          ...displayedFolder.cozyMetadata,\n          favorite: !isFavorite\n        }\n      })\n\n      const { filename } = splitFilename(displayedFolder)\n      showAlert({\n        message: t(`favorites.success.${labelKey}`, {\n          filename,\n          smart_count: 1\n        }),\n        severity: 'success'\n      })\n    } catch (_error) {\n      showAlert({ message: t('favorites.error'), severity: 'error' })\n    }\n  }\n\n  const icon = isFavorite ? StarIcon : StarOutlineIcon\n\n  return (\n    <ActionsMenuItem onClick={handleClick}>\n      <ListItemIcon>\n        <Icon icon={icon} />\n      </ListItemIcon>\n      <ListItemText primary={t(`favorites.label.${labelKey}`)} />\n    </ActionsMenuItem>\n  )\n}\n\nexport default FavoritesItem\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/InsideRegularFolder.jsx",
    "content": "import { ROOT_DIR_ID } from '@/constants/config'\n\n/**\n * Displays its children only if we are in a normal folder (eg. not the root folder or a special view like sharings or recent)\n */\nconst InsideRegularFolder = ({ children, displayedFolder, folderId }) => {\n  const insideRegularFolder =\n    folderId && displayedFolder && displayedFolder.id !== ROOT_DIR_ID\n\n  if (insideRegularFolder) {\n    return children\n  }\n  return null\n}\n\nexport default InsideRegularFolder\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/InsideRegularFolder.spec.jsx",
    "content": "import { render } from '@testing-library/react'\nimport React from 'react'\n\nimport InsideRegularFolder from './InsideRegularFolder'\n\njest.mock('hooks')\n\ndescribe('InsideRegularFolder', () => {\n  it('should return null when insideRegularFolder undefined', () => {\n    const { container } = render(\n      <InsideRegularFolder>\n        <div />\n      </InsideRegularFolder>\n    )\n\n    expect(container).toBeEmptyDOMElement()\n  })\n\n  it('should return children when insideRegularFolder true', () => {\n    const { container } = render(\n      <InsideRegularFolder\n        displayedFolder={{ id: 'displayed-folder' }}\n        folderId=\"current-folder\"\n      >\n        <div />\n      </InsideRegularFolder>\n    )\n\n    expect(container).not.toBeEmptyDOMElement()\n  })\n})\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/LeaveSharedDriveButtonItem.jsx",
    "content": "import React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport LogoutIcon from 'cozy-ui/transpiled/react/Icons/Logout'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { getSharingIdFromRelationships } from '@/modules/shareddrives/helpers'\n\nconst LeaveSharedDriveButtonItem = ({ files }) => {\n  const { t } = useI18n()\n  const client = useClient()\n  const navigate = useNavigate()\n  const { showAlert } = useAlert()\n  const handleClick = async () => {\n    const file = files[0]\n    const sharingId = getSharingIdFromRelationships(file)\n    if (sharingId) {\n      await client.collection('io.cozy.sharings').revokeSelf({ _id: sharingId })\n      showAlert({\n        message: t('Files.share.revokeSelf.success'),\n        severity: 'success'\n      })\n      navigate('/sharings')\n    }\n  }\n\n  return (\n    <ActionsMenuItem onClick={handleClick}>\n      <ListItemIcon>\n        <Icon icon={LogoutIcon} className=\"u-error\" />\n      </ListItemIcon>\n      <ListItemText primary={t('toolbar.menu_leave_shared_drive')} />\n    </ActionsMenuItem>\n  )\n}\n\nexport default LeaveSharedDriveButtonItem\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/MoreMenu.jsx",
    "content": "import React, { useState, useCallback, useRef } from 'react'\n\nimport { useSharingContext } from 'cozy-sharing'\nimport ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'\nimport Divider from 'cozy-ui/transpiled/react/Divider'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { MoreButton } from '@/components/Button'\nimport AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'\nimport AddMenuItem from '@/modules/drive/Toolbar/components/AddMenuItem'\nimport DownloadButtonItem from '@/modules/drive/Toolbar/components/DownloadButtonItem'\nimport FavoritesItem from '@/modules/drive/Toolbar/components/FavoritesItem'\nimport InsideRegularFolder from '@/modules/drive/Toolbar/components/InsideRegularFolder'\nimport LeaveSharedDriveButtonItem from '@/modules/drive/Toolbar/components/LeaveSharedDriveButtonItem'\nimport DeleteItem from '@/modules/drive/Toolbar/delete/DeleteItem'\nimport MoveItem from '@/modules/drive/Toolbar/move/MoveItem'\nimport PersonalizeFolderItem from '@/modules/drive/Toolbar/personalizeFolder/PersonalizeFolderItem'\nimport SelectableItem from '@/modules/drive/Toolbar/selectable/SelectableItem'\nimport ShareItem from '@/modules/drive/Toolbar/share/ShareItem'\n\nexport const openMenu = setMenuVisible => {\n  setMenuVisible(true)\n}\n\nexport const closeMenu = setMenuVisible => {\n  setMenuVisible(false)\n}\n\nexport const toggleMenu = (menuIsVisible, setMenuVisible) => {\n  if (menuIsVisible) return closeMenu(setMenuVisible)\n  openMenu(setMenuVisible)\n}\n\nconst MoreMenu = ({\n  isDisabled,\n  hasWriteAccess,\n  canUpload,\n  canCreateFolder,\n  displayedFolder,\n  folderId,\n  showSelectionBar,\n  isSelectionBarVisible,\n  isSharedWithMe,\n  isSharedDriveRecipient\n}) => {\n  const [menuIsVisible, setMenuVisible] = useState(false)\n  const anchorRef = useRef()\n  const { isMobile } = useBreakpoints()\n  const { allLoaded } = useSharingContext() // We need to wait for the sharing context to be completely loaded to avoid race conditions\n\n  const handleToggle = useCallback(\n    () => toggleMenu(menuIsVisible, setMenuVisible),\n    [menuIsVisible, setMenuVisible]\n  )\n  const handleClose = useCallback(\n    () => closeMenu(setMenuVisible),\n    [setMenuVisible]\n  )\n\n  return (\n    <div>\n      <div ref={anchorRef}>\n        <MoreButton onClick={handleToggle} disabled={isDisabled} />\n      </div>\n      <AddMenuProvider\n        canCreateFolder={canCreateFolder}\n        canUpload={canUpload}\n        disabled={isDisabled}\n        displayedFolder={displayedFolder}\n        isSelectionBarVisible={isSelectionBarVisible}\n      >\n        {menuIsVisible && (\n          <ActionsMenu\n            open\n            ref={anchorRef}\n            onClose={handleClose}\n            docs={[displayedFolder]}\n            actions={[]}\n            anchorOrigin={{\n              vertical: 'bottom',\n              horizontal: 'right'\n            }}\n          >\n            {allLoaded && (\n              <InsideRegularFolder\n                displayedFolder={displayedFolder}\n                folderId={folderId}\n              >\n                <ShareItem displayedFolder={displayedFolder} />\n              </InsideRegularFolder>\n            )}\n            <InsideRegularFolder\n              displayedFolder={displayedFolder}\n              folderId={folderId}\n            >\n              <DownloadButtonItem files={[displayedFolder]} />\n              <MoveItem\n                displayedFolder={displayedFolder}\n                hasWriteAccess={hasWriteAccess}\n              />\n              {!isSharedDriveRecipient && (\n                <PersonalizeFolderItem\n                  displayedFolder={displayedFolder}\n                  hasWriteAccess={hasWriteAccess}\n                />\n              )}\n            </InsideRegularFolder>\n            {isMobile && hasWriteAccess && <AddMenuItem />}\n            <SelectableItem onClick={showSelectionBar} />\n            {hasWriteAccess && !isSharedDriveRecipient && (\n              <InsideRegularFolder\n                displayedFolder={displayedFolder}\n                folderId={folderId}\n              >\n                <FavoritesItem displayedFolder={displayedFolder} />\n              </InsideRegularFolder>\n            )}\n            {hasWriteAccess && (\n              <InsideRegularFolder\n                displayedFolder={displayedFolder}\n                folderId={folderId}\n              >\n                <Divider className=\"u-mv-half\" />\n                <DeleteItem\n                  displayedFolder={displayedFolder}\n                  isSharedWithMe={isSharedWithMe}\n                />\n              </InsideRegularFolder>\n            )}\n            {isSharedDriveRecipient && isSharedWithMe && (\n              <LeaveSharedDriveButtonItem files={[displayedFolder]} />\n            )}\n          </ActionsMenu>\n        )}\n      </AddMenuProvider>\n    </div>\n  )\n}\n\nexport default React.memo(MoreMenu)\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/MoreMenu.spec.jsx",
    "content": "import { render, fireEvent } from '@testing-library/react'\nimport React from 'react'\n\nimport MoreMenu from './MoreMenu'\nimport AppLike from 'test/components/AppLike'\nimport { setupFolderContent, mockCozyClientRequestQuery } from 'test/setup'\n\nimport { downloadFiles } from '@/modules/actions/utils'\n\njest.mock('modules/actions/utils', () => ({\n  downloadFiles: jest.fn().mockResolvedValue()\n}))\n\nmockCozyClientRequestQuery()\n\ndescribe('MoreMenu', () => {\n  const setup = async ({ folderId = 'directory-foobar0' } = {}) => {\n    const { client, store } = await setupFolderContent({\n      folderId\n    })\n\n    client.stackClient.uri = 'http://cozy.tools'\n\n    const result = render(\n      <AppLike client={client} store={store}>\n        <MoreMenu\n          isDisabled={false}\n          canCreateFolder={false}\n          canUpload\n          hasWriteAccess\n          displayedFolder={{ id: 'id2' }}\n          folderId=\"id1\"\n          showSelectionBar={jest.fn()}\n        />\n      </AppLike>\n    )\n\n    const { getByTestId } = result\n    fireEvent.click(getByTestId('more-button'))\n\n    return { ...result, store, client }\n  }\n\n  describe('DownloadButton', () => {\n    it('download files', async () => {\n      // TODO: remove it when DeleteItem get props\n      jest.spyOn(console, 'error').mockImplementation()\n      // TODO : Fix https://github.com/cozy/cozy-drive/issues/2913\n      jest.spyOn(console, 'warn').mockImplementation()\n\n      const { getByText } = await setup()\n\n      fireEvent.click(getByText('Download folder'))\n      expect(downloadFiles).toHaveBeenCalled()\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/Scanner/Scanner.spec.tsx",
    "content": "import { render, fireEvent, waitFor } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\nimport { useWebviewIntent } from 'cozy-intent'\n\n// @ts-expect-error Component is not typed\nimport AppLike from 'test/components/AppLike'\n\nimport { ScannerMenuItem } from '@/modules/drive/Toolbar/components/Scanner/ScannerMenuItem'\nimport { ScannerProvider } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider'\nimport { uploadFiles } from '@/modules/navigation/duck'\n\nconst MockApp = ({ id = 'test', onClick = jest.fn() }): JSX.Element => (\n  <AppLike client={createMockClient()}>\n    <ScannerProvider displayedFolder={{ id }}>\n      <ScannerMenuItem onClick={onClick} />\n    </ScannerProvider>\n  </AppLike>\n)\n\njest.mock('cozy-device-helper', () => ({\n  ...jest.requireActual('cozy-device-helper'),\n  isFlagshipApp: (): boolean => true\n}))\n\nconst mockUseWebviewIntent = useWebviewIntent as jest.Mock\njest.mock('cozy-intent', () => ({\n  useWebviewIntent: jest.fn()\n}))\n\nconst mockUploadFiles = uploadFiles as jest.Mock\njest.mock('modules/navigation/duck', () => ({\n  uploadFiles: jest\n    .fn()\n    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment\n    .mockImplementation(arg => ({ type: 'test', payload: arg }))\n}))\n\njest.spyOn(console, 'log').mockImplementation(() => jest.fn())\n\n// Test suite for the Scanner functionality\ndescribe('Scanner', () => {\n  // Before each test, clear all mocks to ensure a clean state\n  beforeEach(() => {\n    jest.clearAllMocks()\n  })\n\n  // Test case: Ensure that nothing is rendered if the scanner is not available\n  it('renders nothing if the scanner is not available', () => {\n    // Mock the useWebviewIntent hook to always return false for scanner availability\n    mockUseWebviewIntent.mockReturnValue({\n      call: jest.fn().mockResolvedValue(false)\n    })\n\n    // Render the component under test\n    const { queryByTestId } = render(<MockApp />)\n\n    // Assert that the scan-doc element is not present in the DOM\n    expect(queryByTestId('scan-doc')).toBeNull()\n  })\n\n  // Test case: Check if an ActionMenuItem is rendered when the scanner is available\n  it('renders an ActionMenuItem if the folder is available', async () => {\n    // Mock the useWebviewIntent hook to simulate scanner availability\n    mockUseWebviewIntent.mockReturnValue({\n      call: jest.fn((method, arg) => {\n        if (method === 'isAvailable' && arg === 'scanner') {\n          return Promise.resolve(true)\n        }\n        return Promise.resolve(false)\n      })\n    })\n\n    // Render the component under test\n    const { queryByTestId } = render(<MockApp />)\n\n    // Wait for the scanner to become available and assert that the scan-doc element is present\n    await waitFor(() => {\n      expect(queryByTestId('scan-doc')).not.toBeNull()\n    })\n  })\n\n  // Test case: Simulate a click event and verify the startScanner function is called\n  it('calls the startScanner function on click', async () => {\n    // Mock the useWebviewIntent hook with custom logic for scanner availability and document scanning\n    mockUseWebviewIntent.mockReturnValue({\n      call: jest.fn((method, arg) => {\n        if (method === 'isAvailable' && arg === 'scanner') {\n          return Promise.resolve(true)\n        }\n        if (method === 'scanDocument') {\n          return Promise.resolve('base64jpeg')\n        }\n        return Promise.resolve(false)\n      })\n    })\n    const onClickMock = jest.fn()\n\n    // Render the component under test\n    const { queryByTestId } = render(<MockApp onClick={onClickMock} />)\n\n    // Wait for the scan-doc element to be clickable and then simulate a click event\n    await waitFor(() => {\n      queryByTestId('scan-doc') as HTMLButtonElement\n      fireEvent.click(\n        queryByTestId('scan-doc')?.firstChild as HTMLButtonElement\n      )\n    })\n\n    // Create a mock File object\n    const mockFile = new File([], 'testfile')\n\n    // Assert that mockUploadFiles was called once with the expected arguments\n    expect(mockUploadFiles).toHaveBeenCalledTimes(1)\n\n    const calls = mockUploadFiles.mock.calls as unknown[][]\n\n    expect(onClickMock).toHaveBeenCalledTimes(1)\n    expect(calls[0][0]).toEqual([mockFile]) // File\n    expect(calls[0][1]).toBe('test') // Directory ID\n    expect(calls[0][2]).toEqual({ isScanned: true }) // Upload options\n    expect(typeof calls[0][3]).toBe('function') // Success callback\n    // Dependencies\n    expect(calls[0][4]).toMatchObject({\n      client: expect.anything() as Record<string, unknown>,\n      t: expect.anything() as (key: string) => string\n    })\n  })\n\n  // Test case: Handle unexpected errors gracefully\n  it('handles unexpected errors', async () => {\n    const mockConsoleError = jest\n      .spyOn(console, 'log')\n      .mockImplementation(() => {\n        // noop\n      })\n\n    // Mock the useWebviewIntent hook to throw an error\n    mockUseWebviewIntent.mockReturnValue({\n      call: jest.fn((method, arg) => {\n        if (method === 'isAvailable' && arg === 'scanner') {\n          return Promise.resolve(true)\n        }\n\n        if (method === 'scanDocument') {\n          return Promise.reject(new Error('test error'))\n        }\n\n        return Promise.resolve(false)\n      })\n    })\n\n    // Render the component under test\n    const { queryByTestId } = render(<MockApp />)\n\n    // Wait for the scan-doc element to be clickable and then simulate a click event\n    await waitFor(() => {\n      queryByTestId('scan-doc') as HTMLButtonElement\n      fireEvent.click(\n        queryByTestId('scan-doc')?.firstChild as HTMLButtonElement\n      )\n    })\n\n    // Wait for the component to react to the error and assert that the scan-doc element is not present\n    await waitFor(() => {\n      expect(\n        // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n        mockConsoleError.mock.calls.some(call => call[0].includes('test error'))\n      ).toBe(true)\n      expect(queryByTestId('scan-doc')).not.toBeNull()\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/Scanner/ScannerMenuItem.tsx",
    "content": "import React from 'react'\n\nimport logger from 'cozy-logger'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport CameraIcon from 'cozy-ui/transpiled/react/Icons/Camera'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useI18n } from 'twake-i18n'\n\nimport { useScannerContext } from '@/modules/drive/Toolbar/components/Scanner/ScannerProvider'\n\nconst log = logger.namespace('Toolbar/components/Scanner/ScannerMenuItem')\n\n/**\n * Renders a scanner menu item.\n * @returns The JSX element representing the scanner menu item.\n */\ninterface ScannerMenuItemProps {\n  onClick: () => void\n}\n\nexport const ScannerMenuItem = ({\n  onClick\n}: ScannerMenuItemProps): JSX.Element | null => {\n  const { t } = useI18n()\n  const { hasScanner, startScanner } = useScannerContext()\n\n  const handleClick = (): void => {\n    if (startScanner) {\n      startScanner().catch((error: Error) => {\n        log('error', `Failed to start scanner: ${error.message}`)\n      })\n    }\n    onClick()\n  }\n\n  return hasScanner ? (\n    <ActionsMenuItem onClick={handleClick} data-testid=\"scan-doc\">\n      <ListItemIcon>\n        <Icon icon={CameraIcon} />\n      </ListItemIcon>\n      <ListItemText primary={t('Scan.scan_a_doc')} />\n    </ActionsMenuItem>\n  ) : null\n}\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/Scanner/ScannerProvider.tsx",
    "content": "import React, { useContext } from 'react'\n\nimport { useScannerService } from '@/modules/drive/Toolbar/components/Scanner/useScannerService'\n\ninterface ScannerContextValue {\n  startScanner?: () => Promise<void>\n  hasScanner: boolean\n}\n\ninterface ScannerProviderProps {\n  children: React.ReactNode\n  displayedFolder: { id: string }\n}\n\n/**\n * Context object for the Scanner component.\n */\nexport const ScannerContext = React.createContext<ScannerContextValue>({\n  startScanner: undefined,\n  hasScanner: false\n})\n\nexport const useScannerContext = (): ScannerContextValue =>\n  useContext(ScannerContext)\n\n/**\n * Provides the scanner functionality.\n *\n * @param props - The component props.\n * @returns The scanner provider component.\n */\nexport const ScannerProvider = ({\n  children,\n  displayedFolder\n}: ScannerProviderProps): JSX.Element => {\n  const scanner = useScannerService(displayedFolder)\n\n  return (\n    <ScannerContext.Provider value={scanner}>\n      {children}\n    </ScannerContext.Provider>\n  )\n}\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/Scanner/useScannerService.ts",
    "content": "import { useState, useEffect, useCallback } from 'react'\nimport { useDispatch } from 'react-redux'\n\nimport { useClient } from 'cozy-client'\nimport { useWebviewIntent } from 'cozy-intent'\nimport logger from 'cozy-logger'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport {\n  getErrorMessage,\n  getFileFromBase64,\n  getUniqueNameFromPrefix\n} from '@/modules/drive/helpers'\nimport { uploadFiles } from '@/modules/navigation/duck'\n\n/**\n * Custom hook that provides scanner functionality.\n * @returns An object with the following properties:\n *   - hasScanner: A boolean indicating whether the scanner is available.\n *   - scanDocument: A function that returns a promise resolving to a string representing the scanned document in base64 format.\n */\nexport const useScannerService = (displayedFolder: {\n  id: string\n  driveId: string\n}): {\n  hasScanner: boolean\n  startScanner: () => Promise<void>\n} => {\n  const [hasScanner, setHasScanner] = useState(false)\n  const webviewIntent = useWebviewIntent()\n  const dispatch = useDispatch()\n  const client = useClient()\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n\n  useEffect(() => {\n    const initScanner = async (): Promise<void> => {\n      try {\n        const res = await webviewIntent?.call('isAvailable', 'scanner')\n        setHasScanner(Boolean(res))\n      } catch (error) {\n        logger('error', `scanner won't be available, ${getErrorMessage(error)}`)\n      }\n    }\n\n    if (webviewIntent) {\n      void initScanner()\n    }\n  }, [webviewIntent])\n\n  const scanDocument = useCallback(async (): Promise<string> => {\n    logger('info', 'Starting scanner')\n    const base64 = (await webviewIntent?.call(\n      'scanDocument'\n    )) as unknown as string\n\n    if (!base64) throw new Error('No base64 returned by scanDocument')\n\n    logger('info', `Scan done, base64 trimmed: ${base64.slice(0, 20)}...`)\n    return base64\n  }, [webviewIntent])\n\n  const startScanner = useCallback(async () => {\n    try {\n      if (!displayedFolder) return\n\n      const base64 = await scanDocument()\n\n      const payload = uploadFiles(\n        [\n          getFileFromBase64(\n            base64,\n            getUniqueNameFromPrefix('scan'),\n            'image/jpeg'\n          )\n        ],\n        displayedFolder.id,\n        { isScanned: true },\n        () => logger('info', `File uploaded successfully`),\n        { client, showAlert, t },\n        displayedFolder.driveId,\n        undefined\n      )\n\n      dispatch(payload)\n    } catch (error) {\n      logger('error', `startScanner error, ${getErrorMessage(error)}`)\n      showAlert({ message: t('ImportToDrive.error'), severity: 'error' })\n    }\n  }, [displayedFolder, scanDocument, dispatch, client, t, showAlert])\n\n  return { hasScanner, startScanner }\n}\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/SearchButton.jsx",
    "content": "import React, { useCallback } from 'react'\nimport { useLocation, useNavigate } from 'react-router-dom'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport Magnifier from 'cozy-ui/transpiled/react/Icons/Magnifier'\nimport { useI18n } from 'twake-i18n'\n\nconst SearchButton = () => {\n  const { t } = useI18n()\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n\n  const goToSearch = useCallback(() => {\n    navigate(`/search?returnPath=${pathname}`)\n  }, [navigate, pathname])\n\n  return (\n    <IconButton onClick={goToSearch} aria-label={t('search.action')}>\n      <Icon icon={Magnifier} />\n    </IconButton>\n  )\n}\n\nexport default SearchButton\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/ShortcutCreationModal.jsx",
    "content": "import React, { useCallback, useEffect, useState } from 'react'\n\nimport { useClient } from 'cozy-client'\nimport { isIOS } from 'cozy-device-helper'\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport { FixedDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport InputAdornment from 'cozy-ui/transpiled/react/InputAdornment'\nimport Stack from 'cozy-ui/transpiled/react/Stack'\nimport TextField from 'cozy-ui/transpiled/react/TextField'\nimport useBrowserOffline from 'cozy-ui/transpiled/react/hooks/useBrowserOffline'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { useDisplayedFolder } from '@/hooks'\nimport { displayedFolderOrRootFolder } from '@/hooks/helpers'\nimport { DOCTYPE_FILES_SHORTCUT } from '@/lib/doctypes'\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\nconst ENTER_KEY = 13\n\nconst isURLValid = url => {\n  try {\n    new URL(url)\n    return true\n  } catch (_e) {\n    return false\n  }\n}\n\nconst makeURLValid = str => {\n  if (isURLValid(str)) return str\n  else if (isURLValid(`https://${str}`)) return `https://${str}`\n  return false\n}\nconst ShortcutCreationModal = ({ onClose, onCreated }) => {\n  const { displayedFolder } = useDisplayedFolder()\n  const { t } = useI18n()\n  const [fileName, setFilename] = useState('')\n  const [url, setUrl] = useState('')\n  const client = useClient()\n  const { showAlert } = useAlert()\n  const isOffline = useBrowserOffline()\n  const { addItems } = useNewItemHighlightContext()\n\n  const _displayedFolder = displayedFolderOrRootFolder(displayedFolder)\n\n  const createShortcut = useCallback(async () => {\n    if (!fileName || !url) {\n      showAlert({ message: t('Shortcut.needs_info'), severity: 'error' })\n      return\n    }\n    const makedURL = makeURLValid(url)\n    if (!makedURL) {\n      showAlert({ message: t('Shortcut.url_badformat'), severity: 'error' })\n      return\n    }\n    try {\n      if (isOffline) {\n        showAlert({ message: t('alert.offline'), severity: 'error' })\n      } else {\n        const response = await client.save({\n          _type: DOCTYPE_FILES_SHORTCUT,\n          dir_id: _displayedFolder.id,\n          name: fileName.endsWith('.url') ? fileName : fileName + '.url',\n          url: makedURL\n        })\n        const createdShortcut = response?.data ?? response\n        if (createdShortcut) {\n          addItems([createdShortcut])\n        }\n        showAlert({ message: t('Shortcut.created'), severity: 'success' })\n        if (onCreated) onCreated()\n      }\n      onClose()\n    } catch (error) {\n      if (\n        error.message.includes(\n          'NetworkError when attempting to fetch resource.'\n        )\n      ) {\n        showAlert({ message: t('upload.alert.network'), severity: 'error' })\n      } else if (\n        error.message.includes(\n          'Invalid filename containing illegal character(s):'\n        )\n      ) {\n        showAlert({\n          message: t('alert.file_name_illegal_characters', {\n            fileName,\n            characters: error.message.split(\n              'Invalid filename containing illegal character(s): '\n            )[1]\n          }),\n          severity: 'error',\n          duration: 2000\n        })\n      } else if (error.message.includes('Invalid filename:')) {\n        showAlert({\n          message: t('alert.file_name_illegal_name', { fileName }),\n          severity: 'error'\n        })\n      } else if (error.message.includes('Missing name argument')) {\n        showAlert({ message: t('alert.file_name_missing'), severity: 'error' })\n      } else {\n        showAlert({ message: t('Shortcut.errored'), severity: 'error' })\n      }\n    }\n  }, [\n    client,\n    fileName,\n    onClose,\n    onCreated,\n    t,\n    url,\n    _displayedFolder,\n    isOffline,\n    showAlert,\n    addItems\n  ])\n\n  const handleKeyDown = e => {\n    if (e.keyCode === ENTER_KEY) {\n      createShortcut()\n    }\n  }\n\n  useEffect(() => {\n    const timeout = setTimeout(() => {\n      if (isIOS()) window.scrollTo(0, 0)\n    }, 30)\n\n    return () => clearTimeout(timeout)\n  }, [])\n\n  return (\n    <FixedDialog\n      onClose={onClose}\n      title={t('Shortcut.title_modal')}\n      open={true}\n      content={\n        <Stack>\n          <div>\n            <TextField\n              label={t('Shortcut.url')}\n              id=\"shortcuturl\"\n              variant=\"outlined\"\n              onChange={e => setUrl(e.target.value)}\n              onKeyDown={e => handleKeyDown(e)}\n              fullWidth\n              margin=\"normal\"\n              autoFocus\n            />\n          </div>\n          <div>\n            <TextField\n              label={t('Shortcut.filename')}\n              id=\"shortcutfilename\"\n              variant=\"outlined\"\n              onChange={e => setFilename(e.target.value)}\n              fullWidth\n              margin=\"normal\"\n              onKeyDown={e => handleKeyDown(e)}\n              InputProps={{\n                endAdornment: (\n                  <InputAdornment position=\"end\">.url</InputAdornment>\n                )\n              }}\n            />\n          </div>\n        </Stack>\n      }\n      actions={\n        <>\n          <Button\n            variant=\"secondary\"\n            onClick={onClose}\n            label={t('Shortcut.cancel')}\n          />\n          <Button\n            variant=\"primary\"\n            label={t('Shortcut.create')}\n            onClick={createShortcut}\n          />\n        </>\n      }\n    />\n  )\n}\n\nexport default ShortcutCreationModal\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/ShortcutCreationModal.spec.jsx",
    "content": "import { fireEvent, render, waitFor } from '@testing-library/react'\nimport mediaQuery from 'css-mediaquery'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\nimport useBrowserOffline from 'cozy-ui/transpiled/react/hooks/useBrowserOffline'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\n\nimport ShortcutCreationModal from './ShortcutCreationModal'\nimport AppLike from 'test/components/AppLike'\n\nimport useDisplayedFolder from '@/hooks/useDisplayedFolder'\nimport { DOCTYPE_FILES_SHORTCUT } from '@/lib/doctypes'\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\nconst tMock = jest.fn()\nconst showAlert = jest.fn()\n\njest.mock('cozy-ui/transpiled/react/hooks/useBrowserOffline')\njest.mock('cozy-ui/transpiled/react/providers/Alert', () => ({\n  ...jest.requireActual('cozy-ui/transpiled/react/providers/Alert'),\n  __esModule: true,\n  useAlert: jest.fn()\n}))\n\njest.mock('lib/logger', () => ({\n  error: jest.fn()\n}))\njest.mock('hooks/useDisplayedFolder')\njest.mock('@/modules/upload/NewItemHighlightProvider', () => {\n  const React = require('react')\n  return {\n    __esModule: true,\n    NewItemHighlightProvider: ({ children }) => <>{children}</>,\n    useNewItemHighlightContext: jest.fn()\n  }\n})\n\nfunction createMatchMedia(width) {\n  return query => ({\n    matches: mediaQuery.match(query, { width }),\n    addListener: () => {},\n    removeListener: () => {}\n  })\n}\nconst client = new createMockClient({})\nconst onCloseSpy = jest.fn()\nconst addItemsMock = jest.fn()\nconst defaultProps = {\n  displayedFolder: {\n    id: 'id'\n  },\n  onClose: onCloseSpy,\n  open: true\n}\n\ndescribe('ShortcutCreationModal', () => {\n  beforeEach(() => {\n    jest.resetAllMocks()\n    useDisplayedFolder.mockReturnValue({ displayedFolder: { id: 'id' } })\n    window.matchMedia = createMatchMedia(window.innerWidth)\n    tMock.mockImplementation(key => key)\n    useAlert.mockReturnValue({ showAlert })\n    addItemsMock.mockReset()\n    useNewItemHighlightContext.mockReturnValue({\n      addItems: addItemsMock\n    })\n  })\n\n  const setup = props => {\n    const { getByLabelText, getByText } = render(\n      <AppLike client={client}>\n        <ShortcutCreationModal {...props} />\n      </AppLike>\n    )\n\n    const filenameInput = getByLabelText('Filename')\n    const submitButton = getByText('Create')\n\n    return {\n      urlInput: getByLabelText('URL'),\n      filenameInput,\n      submitButton\n    }\n  }\n\n  it('should display error when filename is empty', async () => {\n    // Given\n    const { urlInput, submitButton } = setup(defaultProps)\n    fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })\n\n    // When\n    fireEvent.click(submitButton)\n\n    // Then\n    expect(client.save).not.toHaveBeenCalled()\n    expect(showAlert).toHaveBeenCalledTimes(1)\n    expect(showAlert).toHaveBeenCalledWith(\n      expect.objectContaining({ severity: 'error' })\n    )\n  })\n\n  it('should handle correctly success case', async () => {\n    // Given\n    const { urlInput, filenameInput, submitButton } = setup(defaultProps)\n    fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })\n    fireEvent.change(filenameInput, { target: { value: 'filename.url' } })\n    client.save.mockResolvedValue({ data: { _id: 'shortcut-id' } })\n\n    // When\n    fireEvent.click(submitButton)\n\n    // Then\n    expect(client.save).toHaveBeenCalledWith({\n      dir_id: 'id',\n      name: 'filename.url',\n      _type: DOCTYPE_FILES_SHORTCUT,\n      url: 'https://cozy.io'\n    })\n\n    await waitFor(() => {\n      expect(showAlert).toHaveBeenCalledTimes(1)\n      expect(showAlert).toHaveBeenCalledWith(\n        expect.objectContaining({ severity: 'success' })\n      )\n    })\n    expect(addItemsMock).toHaveBeenCalledWith([\n      expect.objectContaining({ _id: 'shortcut-id' })\n    ])\n  })\n\n  it('should call the optional onCreated prop', async () => {\n    const onCreatedMock = jest.fn()\n    const { urlInput, filenameInput, submitButton } = setup({\n      ...defaultProps,\n      onCreated: onCreatedMock\n    })\n\n    client.save.mockResolvedValue({ data: {} })\n    fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })\n    fireEvent.change(filenameInput, { target: { value: 'filename' } })\n    fireEvent.click(submitButton)\n\n    await waitFor(() => {\n      expect(showAlert).toHaveBeenCalledTimes(1)\n      expect(showAlert).toHaveBeenCalledWith(\n        expect.objectContaining({ severity: 'success' })\n      )\n    })\n    expect(onCreatedMock).toHaveBeenCalled()\n  })\n\n  it('should alert error on illegal characters', async () => {\n    const { urlInput, filenameInput, submitButton } = setup({\n      ...defaultProps,\n      onCreated: jest.fn()\n    })\n\n    client.save.mockRejectedValue({\n      message: 'Invalid filename containing illegal character(s): /'\n    })\n\n    fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })\n    fireEvent.change(filenameInput, { target: { value: 'file/name' } })\n    fireEvent.click(submitButton)\n\n    await waitFor(() => {\n      expect(showAlert).toHaveBeenCalledTimes(1)\n      expect(showAlert).toHaveBeenCalledWith(\n        expect.objectContaining({ severity: 'error', duration: 2000 })\n      )\n    })\n  })\n\n  it('should alert error on illegal file name', async () => {\n    const { urlInput, filenameInput, submitButton } = setup({\n      ...defaultProps,\n      onCreated: jest.fn()\n    })\n\n    client.save.mockRejectedValue({\n      message: 'Invalid filename: ..'\n    })\n\n    fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })\n    fireEvent.change(filenameInput, { target: { value: '..' } })\n    fireEvent.click(submitButton)\n\n    await waitFor(() => {\n      expect(showAlert).toHaveBeenCalledTimes(1)\n      expect(showAlert).toHaveBeenCalledWith(\n        expect.objectContaining({ severity: 'error' })\n      )\n    })\n  })\n\n  it('should alert error on missing file name', async () => {\n    const { urlInput, filenameInput, submitButton } = setup({\n      ...defaultProps,\n      onCreated: jest.fn()\n    })\n\n    client.save.mockRejectedValue({\n      message: 'Missing name argument'\n    })\n\n    fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })\n    fireEvent.change(filenameInput, { target: { value: '   ' } })\n    fireEvent.click(submitButton)\n\n    await waitFor(() => {\n      expect(showAlert).toHaveBeenCalledTimes(1)\n      expect(showAlert).toHaveBeenCalledWith(\n        expect.objectContaining({ severity: 'error' })\n      )\n    })\n  })\n\n  it('should alert network error when detected by useBrowserOffline', async () => {\n    useBrowserOffline.mockReturnValue(true)\n    const { urlInput, filenameInput, submitButton } = setup({\n      ...defaultProps,\n      onCreated: jest.fn()\n    })\n\n    fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })\n    fireEvent.change(filenameInput, { target: { value: '   ' } })\n    fireEvent.click(submitButton)\n\n    await waitFor(() => {\n      expect(showAlert).toHaveBeenCalledTimes(1)\n      expect(showAlert).toHaveBeenCalledWith(\n        expect.objectContaining({ severity: 'error' })\n      )\n    })\n  })\n\n  it('should alert network error when not detected by useBrowserOffline', async () => {\n    const { urlInput, filenameInput, submitButton } = setup({\n      ...defaultProps,\n      onCreated: jest.fn()\n    })\n\n    client.save.mockRejectedValue({\n      message: 'NetworkError when attempting to fetch resource.'\n    })\n\n    fireEvent.change(urlInput, { target: { value: 'https://cozy.io' } })\n    fireEvent.change(filenameInput, { target: { value: '   ' } })\n    fireEvent.click(submitButton)\n\n    await waitFor(() => {\n      expect(showAlert).toHaveBeenCalledTimes(1)\n      expect(showAlert).toHaveBeenCalledWith(\n        expect.objectContaining({ severity: 'error' })\n      )\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/UploadItem.jsx",
    "content": "import React from 'react'\nimport { useDispatch } from 'react-redux'\n\nimport { useClient } from 'cozy-client'\nimport withSharingState from 'cozy-sharing/dist/hoc/withSharingState'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport FileInput from 'cozy-ui/transpiled/react/FileInput'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport UploadIcon from 'cozy-ui/transpiled/react/Icons/Upload'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { useDisplayedFolder } from '@/hooks'\nimport { uploadFiles } from '@/modules/navigation/duck'\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\nconst UploadItem = ({\n  onClick,\n  isReadOnly,\n  displayedFolder,\n  sharingState,\n  onUploaded\n}) => {\n  const client = useClient()\n  const { showAlert } = useAlert()\n  const { initialDirId } = useDisplayedFolder()\n  const { addItems } = useNewItemHighlightContext()\n  const { t } = useI18n()\n  const dispatch = useDispatch()\n\n  const onUpload = (\n    client,\n    files,\n    initialDirId,\n    showAlert,\n    driveId,\n    addItems\n  ) => {\n    dispatch(\n      uploadFiles(\n        files,\n        initialDirId,\n        sharingState,\n        onUploaded,\n        { client, showAlert, t },\n        driveId,\n        addItems\n      )\n    )\n  }\n\n  const handleMenuItemClick = evt => {\n    if (isReadOnly) {\n      evt.preventDefault()\n      evt.stopPropagation()\n\n      showAlert({\n        message: t(\n          'AddMenu.readOnlyFolder',\n          'This is a read-only folder. You cannot perform this action.'\n        ),\n        severity: 'warning'\n      })\n      onClick()\n      return\n    }\n  }\n\n  const handleChange = files => {\n    if (isReadOnly || !files || files.length === 0) return\n\n    onUpload(\n      client,\n      files,\n      initialDirId,\n      showAlert,\n      displayedFolder?.driveId,\n      addItems\n    )\n    onClick()\n  }\n\n  return (\n    <FileInput\n      label={t('toolbar.menu_upload')}\n      multiple\n      onChange={handleChange}\n      data-testid=\"upload-btn\"\n      value={[]}\n    >\n      <ActionsMenuItem onClick={handleMenuItemClick}>\n        <ListItemIcon>\n          <Icon icon={UploadIcon} />\n        </ListItemIcon>\n        <ListItemText primary={t('toolbar.menu_upload')} />\n      </ActionsMenuItem>\n    </FileInput>\n  )\n}\n\nexport default withSharingState(UploadItem)\n"
  },
  {
    "path": "src/modules/drive/Toolbar/components/ViewSwitcher.jsx",
    "content": "import React from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ListMinIcon from 'cozy-ui/transpiled/react/Icons/ListMin'\nimport MosaicMinIcon from 'cozy-ui/transpiled/react/Icons/MosaicMin'\nimport ToggleButton from 'cozy-ui/transpiled/react/ToggleButton'\nimport ToggleButtonGroup from 'cozy-ui/transpiled/react/ToggleButtonGroup'\nimport { useI18n } from 'twake-i18n'\n\nimport { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'\n\n/**\n * ViewSwitcher component for toggling between grid and list views\n * @param {Object} props - Component props\n * @param {string} props.className - Additional CSS class name\n * @returns {JSX.Element} The rendered component\n */\nconst ViewSwitcher = ({ className }) => {\n  const { t } = useI18n()\n  const { viewType, switchView } = useViewSwitcherContext()\n\n  // Convert isBigThumbnail to value for ToggleButtonGroup\n  const value = viewType\n\n  const handleChange = (event, newValue) => {\n    if (newValue !== null) {\n      switchView(newValue)\n    }\n  }\n\n  return (\n    <ToggleButtonGroup\n      value={value}\n      exclusive\n      onChange={handleChange}\n      aria-label={t('table.head_view_mode')}\n      size=\"small\"\n      className={className}\n      variant=\"rounded\"\n    >\n      <ToggleButton\n        value=\"list\"\n        aria-label={t('table.head_view_list')}\n        rounded={true}\n        color=\"default\"\n      >\n        <Icon icon={ListMinIcon} />\n      </ToggleButton>\n      <ToggleButton\n        value=\"grid\"\n        aria-label={t('table.head_view_grid')}\n        rounded={true}\n        color=\"default\"\n      >\n        <Icon icon={MosaicMinIcon} />\n      </ToggleButton>\n    </ToggleButtonGroup>\n  )\n}\n\nexport default ViewSwitcher\n"
  },
  {
    "path": "src/modules/drive/Toolbar/delete/DeleteItem.jsx",
    "content": "import compose from 'lodash/flowRight'\nimport PropTypes from 'prop-types'\nimport React from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { translate } from 'twake-i18n'\n\nimport deleteContainer from './delete'\n\nconst DeleteItem = ({ t, isSharedWithMe, trashFolder, displayedFolder }) => {\n  const handleClick = () => {\n    trashFolder(displayedFolder)\n  }\n\n  const label = isSharedWithMe ? t('toolbar.leave') : t('toolbar.trash')\n\n  return (\n    <ActionsMenuItem data-testid=\"fil-action-delete\" onClick={handleClick}>\n      <ListItemIcon>\n        <Icon icon={TrashIcon} color=\"var(--errorColor)\" />\n      </ListItemIcon>\n      <ListItemText style={{ color: 'var(--errorColor)' }} primary={label} />\n    </ActionsMenuItem>\n  )\n}\n\nDeleteItem.propTypes = {\n  t: PropTypes.func.isRequired,\n  isSharedWithMe: PropTypes.bool.isRequired,\n  trashFolder: PropTypes.func.isRequired,\n  displayedFolder: PropTypes.object.isRequired\n}\n\nexport default compose(translate(), deleteContainer)(DeleteItem)\n"
  },
  {
    "path": "src/modules/drive/Toolbar/delete/DeleteItem.spec.jsx",
    "content": "import { render, fireEvent } from '@testing-library/react'\nimport React from 'react'\n\nimport DeleteItem from './DeleteItem'\nimport { EnhancedDeleteConfirm } from './delete'\nimport AppLike from 'test/components/AppLike'\nimport { setupStoreAndClient } from 'test/setup'\n\njest.mock('modules/actions/utils', () => ({\n  trashFiles: jest.fn().mockResolvedValue()\n}))\n\njest.mock('lib/logger', () => ({\n  error: jest.fn()\n}))\n\ndescribe('DeleteItem', () => {\n  const setup = () => {\n    const displayedFolder = {\n      _id: 'displayed-folder-id',\n      name: 'My Folder'\n    }\n    const { client, store } = setupStoreAndClient({})\n\n    jest.spyOn(store, 'dispatch')\n    const onLeave = jest.fn()\n    const container = render(\n      <AppLike client={client} store={store}>\n        <DeleteItem\n          isSharedWithMe={false}\n          onLeave={onLeave}\n          displayedFolder={displayedFolder}\n        />\n      </AppLike>\n    )\n    return { container, store, displayedFolder }\n  }\n\n  it('should show a modal', async () => {\n    const { container, store, displayedFolder } = setup()\n    const confirmButton = container.getByText('Remove')\n    fireEvent.click(confirmButton)\n    expect(store.dispatch).toHaveBeenCalledWith(\n      expect.objectContaining({\n        type: 'SHOW_MODAL',\n        component: expect.objectContaining({\n          type: EnhancedDeleteConfirm,\n          props: expect.objectContaining({\n            folder: displayedFolder\n          })\n        })\n      })\n    )\n  })\n})\n"
  },
  {
    "path": "src/modules/drive/Toolbar/delete/delete.jsx",
    "content": "import React, { useCallback } from 'react'\nimport { connect } from 'react-redux'\nimport { useNavigate } from 'react-router-dom'\n\nimport DeleteConfirm from '../../DeleteConfirm'\n\nimport { showModal } from '@/lib/react-cozy-helpers'\n\nconst EnhancedDeleteConfirm = ({ folder, ...rest }) => {\n  const navigate = useNavigate()\n\n  const navigateToParentFolder = useCallback(\n    () => navigate(`/folder/${folder.dir_id}`),\n    [navigate, folder]\n  )\n\n  return (\n    <DeleteConfirm\n      files={[folder]}\n      afterConfirmation={navigateToParentFolder}\n      {...rest}\n    />\n  )\n}\n\nexport { EnhancedDeleteConfirm }\n\nconst mapDispatchToProps = dispatch => ({\n  trashFolder: folder =>\n    dispatch(showModal(<EnhancedDeleteConfirm folder={folder} />))\n})\n\nconst deleteContainer = connect(null, mapDispatchToProps)\n\nexport default deleteContainer\n"
  },
  {
    "path": "src/modules/drive/Toolbar/delete/delete.spec.jsx",
    "content": "import { render, fireEvent, waitFor } from '@testing-library/react'\nimport React from 'react'\n\nimport { EnhancedDeleteConfirm } from './delete'\nimport AppLike from 'test/components/AppLike'\nimport { setupStoreAndClient } from 'test/setup'\n\nconst mockNavigate = jest.fn()\n\njest.mock('lib/logger', () => ({\n  error: jest.fn()\n}))\n\njest.mock('react-router-dom', () => ({\n  ...jest.requireActual('react-router-dom'),\n  useNavigate: () => mockNavigate\n}))\n\ndescribe('EnhancedDeleteConfirm', () => {\n  const setup = () => {\n    const folder = {\n      _id: 'folder-id',\n      name: 'My folder',\n      dir_id: 'parent-folder-id'\n    }\n    const { client, store } = setupStoreAndClient({})\n    const mockSharingContext = {\n      hasWriteAccess: () => true,\n      getRecipients: () => [],\n      getSharingLink: () => null\n    }\n    const container = render(\n      <AppLike\n        client={client}\n        store={store}\n        sharingContextValue={mockSharingContext}\n      >\n        <EnhancedDeleteConfirm folder={folder} onClose={() => null} />\n      </AppLike>\n    )\n    return { container, folder, client }\n  }\n\n  it('should trashFiles on confirmation', async () => {\n    const { container } = setup()\n    const confirmButton = container.getByText('Remove')\n    fireEvent.click(confirmButton)\n    await waitFor(() =>\n      expect(mockNavigate).toHaveBeenCalledWith('/folder/parent-folder-id')\n    )\n  })\n})\n"
  },
  {
    "path": "src/modules/drive/Toolbar/index.jsx",
    "content": "import cx from 'classnames'\nimport PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { SharedDocument, useSharingContext } from 'cozy-sharing'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport styles from '@/styles/toolbar.styl'\n\nimport { BarRightOnMobile } from '@/components/Bar'\nimport { useDisplayedFolder, useCurrentFolderId } from '@/hooks'\nimport InsideRegularFolder from '@/modules/drive/Toolbar/components/InsideRegularFolder'\nimport MoreMenu from '@/modules/drive/Toolbar/components/MoreMenu'\nimport SearchButton from '@/modules/drive/Toolbar/components/SearchButton'\nimport ViewSwitcher from '@/modules/drive/Toolbar/components/ViewSwitcher'\nimport ShareButton from '@/modules/drive/Toolbar/share/ShareButton'\nimport SharedRecipients from '@/modules/drive/Toolbar/share/SharedRecipients'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'\n\nconst Toolbar = ({\n  folderId,\n  disabled,\n  canUpload,\n  canCreateFolder,\n  hasWriteAccess,\n  isSharedWithMe,\n  showShareButton = true\n}) => {\n  const { displayedFolder } = useDisplayedFolder()\n  const { isMobile } = useBreakpoints()\n  const { showSelectionBar, isSelectionBarVisible } = useSelectionContext()\n  const { allLoaded } = useSharingContext() // We need to wait for the sharing context to be completely loaded to avoid race conditions\n\n  const isDisabled = disabled || isSelectionBarVisible\n  const isSharingDisabled = isDisabled || !allLoaded\n  const isSharedDriveRecipient = isFromSharedDriveRecipient(displayedFolder)\n\n  const moreMenuProps = {\n    isDisabled,\n    hasWriteAccess,\n    isSharedWithMe,\n    canCreateFolder,\n    canUpload,\n    folderId,\n    displayedFolder,\n    showSelectionBar,\n    isSelectionBarVisible,\n    isSharedDriveRecipient\n  }\n\n  if (disabled) {\n    return null\n  }\n\n  return (\n    <div\n      data-testid=\"fil-toolbar-files\"\n      className={cx(styles['fil-toolbar-files'], 'u-flex-items-center')}\n      role=\"toolbar\"\n    >\n      <InsideRegularFolder\n        displayedFolder={displayedFolder}\n        folderId={folderId}\n      >\n        <SharedRecipients />\n      </InsideRegularFolder>\n      <InsideRegularFolder\n        displayedFolder={displayedFolder}\n        folderId={folderId}\n      >\n        {hasWriteAccess && showShareButton && (\n          <ShareButton\n            isDisabled={isSharingDisabled}\n            useShortLabel={isSharedDriveRecipient}\n            className=\"u-mr-half\"\n          />\n        )}\n      </InsideRegularFolder>\n      <ViewSwitcher className=\"u-mr-half\" />\n      <BarRightOnMobile>\n        {isMobile && <SearchButton />}\n        <MoreMenu {...moreMenuProps} />\n      </BarRightOnMobile>\n    </div>\n  )\n}\n\nToolbar.propTypes = {\n  folderId: PropTypes.string,\n  disabled: PropTypes.bool,\n  canUpload: PropTypes.bool,\n  canCreateFolder: PropTypes.bool,\n  hasWriteAccess: PropTypes.bool\n}\n\nToolbar.defaultProps = {\n  canUpload: false,\n  canCreateFolder: false,\n  hasWriteAccess: false\n}\n\n/**\n * Provides the Toolbar with sharing properties of the current folder.\n *\n * In views where the displayed folder is virtual (eg: Recent files, Sharings),\n * no sharing information is provided to the Toolbar.\n */\nconst ToolbarWithSharingContext = props => {\n  const folderId = useCurrentFolderId()\n  const { driveId } = props\n\n  return !folderId ? (\n    <Toolbar {...props} />\n  ) : (\n    <SharedDocument docId={folderId} driveId={driveId}>\n      {sharingProps => {\n        const { hasWriteAccess, isSharedWithMe } = sharingProps\n        // We do not want to enable write access actions for recipient for shared drive root folder.\n        // To check if it is shared drive root folder, we check if the document is shared because\n        // in a shared drive only the share drive root folder has a sharing\n        const hasWriteAccessExceptSharedDriveRootFolder = driveId\n          ? hasWriteAccess && !isSharedWithMe\n          : hasWriteAccess\n        return (\n          <Toolbar\n            hasWriteAccess={hasWriteAccessExceptSharedDriveRootFolder}\n            isSharedWithMe={isSharedWithMe}\n            folderId={folderId}\n            {...props}\n          />\n        )\n      }}\n    </SharedDocument>\n  )\n}\n\nToolbarWithSharingContext.displayName = 'ToolbarWithSharingContext'\n\nexport default ToolbarWithSharingContext\n"
  },
  {
    "path": "src/modules/drive/Toolbar/move/MoveItem.jsx",
    "content": "import React from 'react'\nimport { useNavigate, useLocation } from 'react-router-dom'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport MovetoIcon from 'cozy-ui/transpiled/react/Icons/Moveto'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { navigateToModalWithMultipleFile } from '@/modules/actions/helpers'\n\nconst MoveItem = ({ displayedFolder, hasWriteAccess }) => {\n  const { t } = useI18n()\n  const navigate = useNavigate()\n  const { pathname, search } = useLocation()\n  const { isMobile } = useBreakpoints()\n\n  if (!hasWriteAccess) {\n    return null\n  }\n\n  const handleClick = () => {\n    navigateToModalWithMultipleFile({\n      files: [displayedFolder],\n      pathname,\n      navigate,\n      path: 'move',\n      search\n    })\n  }\n\n  const label = isMobile\n    ? t('SelectionBar.moveto_mobile')\n    : t('SelectionBar.moveto')\n\n  return (\n    <ActionsMenuItem onClick={handleClick}>\n      <ListItemIcon>\n        <Icon icon={MovetoIcon} />\n      </ListItemIcon>\n      <ListItemText primary={label} />\n    </ActionsMenuItem>\n  )\n}\n\nexport default MoveItem\n"
  },
  {
    "path": "src/modules/drive/Toolbar/personalizeFolder/PersonalizeFolderItem.jsx",
    "content": "import React from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport PaletteIcon from 'cozy-ui/transpiled/react/Icons/Palette'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useI18n } from 'twake-i18n'\n\nimport { useModalContext } from '@/lib/ModalContext'\nimport { FolderCustomizerModal } from '@/modules/views/Folder/FolderCustomizer'\n\nconst PersonalizeFolderItem = ({\n  displayedFolder,\n  hasWriteAccess,\n  onClose\n}) => {\n  const { t } = useI18n()\n  const { pushModal, popModal } = useModalContext()\n\n  if (\n    !hasWriteAccess ||\n    !displayedFolder ||\n    displayedFolder.type !== 'directory'\n  ) {\n    return null\n  }\n\n  const handleClick = () => {\n    pushModal(\n      <FolderCustomizerModal\n        folderId={displayedFolder.id}\n        driveId={displayedFolder.driveId}\n        onClose={() => {\n          popModal()\n          onClose?.()\n        }}\n      />\n    )\n  }\n\n  return (\n    <ActionsMenuItem onClick={handleClick}>\n      <ListItemIcon>\n        <Icon icon={PaletteIcon} />\n      </ListItemIcon>\n      <ListItemText primary={t('actions.personalizeFolder.label')} />\n    </ActionsMenuItem>\n  )\n}\n\nexport default PersonalizeFolderItem\n"
  },
  {
    "path": "src/modules/drive/Toolbar/selectable/SelectableItem.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport CheckSquareIcon from 'cozy-ui/transpiled/react/Icons/CheckSquare'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useI18n } from 'twake-i18n'\n\n/**\n * Action to show the selection bar\n */\nconst SelectableItem = ({ onClick }) => {\n  const { t } = useI18n()\n\n  return (\n    <ActionsMenuItem onClick={onClick}>\n      <ListItemIcon>\n        <Icon icon={CheckSquareIcon} />\n      </ListItemIcon>\n      <ListItemText primary={t('toolbar.menu_select')} />\n    </ActionsMenuItem>\n  )\n}\n\nSelectableItem.propTypes = {\n  onClick: PropTypes.func.isRequired\n}\n\nexport default SelectableItem\n"
  },
  {
    "path": "src/modules/drive/Toolbar/share/ShareButton.jsx",
    "content": "import React from 'react'\nimport { useLocation, useNavigate } from 'react-router-dom'\n\nimport { ShareButton } from 'cozy-sharing'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { useDisplayedFolder } from '@/hooks'\nimport { getPathToShareDisplayedFolder } from '@/modules/drive/Toolbar/share/helpers'\n\nconst ShareButtonWithProps = ({ isDisabled, className, useShortLabel }) => {\n  const { displayedFolder } = useDisplayedFolder()\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n  const { isMobile } = useBreakpoints()\n\n  const share = () => {\n    navigate(getPathToShareDisplayedFolder(pathname))\n  }\n\n  if (!displayedFolder) return null\n\n  return (\n    <ShareButton\n      docId={displayedFolder.id}\n      disabled={isDisabled}\n      useShortLabel={useShortLabel}\n      className={className}\n      onClick={() => share(displayedFolder)}\n      size={isMobile ? 'small' : 'medium'}\n    />\n  )\n}\n\nexport default ShareButtonWithProps\n"
  },
  {
    "path": "src/modules/drive/Toolbar/share/ShareItem.jsx",
    "content": "import React from 'react'\nimport { useNavigate, useLocation } from 'react-router-dom'\n\nimport { SharedDocument } from 'cozy-sharing'\nimport { AvatarList } from 'cozy-sharing/dist/components/Avatar/AvatarList'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useI18n } from 'twake-i18n'\n\nimport { getPathToShareDisplayedFolder } from '@/modules/drive/Toolbar/share/helpers'\n\nconst ShareItem = ({ displayedFolder }) => {\n  const { t } = useI18n()\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n\n  const share = () => {\n    navigate(getPathToShareDisplayedFolder(pathname))\n  }\n\n  return (\n    <SharedDocument docId={displayedFolder.id}>\n      {({ isSharedWithMe, recipients, link }) => (\n        <ActionsMenuItem onClick={share}>\n          <ListItemIcon>\n            <Icon icon={ShareIcon} />\n          </ListItemIcon>\n          <ListItemText\n            primary={t(\n              isSharedWithMe\n                ? 'Files.share.sharedWithMe'\n                : 'toolbar.menu_share_folder'\n            )}\n          />\n          <AvatarList recipients={recipients} link={link} size=\"small\" />\n        </ActionsMenuItem>\n      )}\n    </SharedDocument>\n  )\n}\n\nexport default ShareItem\n"
  },
  {
    "path": "src/modules/drive/Toolbar/share/SharedRecipients.jsx",
    "content": "import React from 'react'\nimport { useLocation, useNavigate } from 'react-router-dom'\n\nimport { SharedRecipients } from 'cozy-sharing'\n\nimport { useDisplayedFolder } from '@/hooks'\nimport { getPathToShareDisplayedFolder } from '@/modules/drive/Toolbar/share/helpers'\n\nconst SharedRecipientsComponent = () => {\n  const { displayedFolder } = useDisplayedFolder()\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n\n  const share = () => {\n    navigate(getPathToShareDisplayedFolder(pathname))\n  }\n\n  return (\n    <SharedRecipients\n      docId={displayedFolder && displayedFolder.id}\n      onClick={share}\n    />\n  )\n}\n\nexport default SharedRecipientsComponent\n"
  },
  {
    "path": "src/modules/drive/Toolbar/share/helpers.js",
    "content": "import { joinPath } from '@/lib/path'\n\n/**\n * Get the path to share the displayed folder\n * @param {string} pathname Current path\n * @returns Next path\n */\nexport function getPathToShareDisplayedFolder(pathname) {\n  return joinPath(pathname, 'share')\n}\n"
  },
  {
    "path": "src/modules/drive/Toolbar/share/helpers.spec.js",
    "content": "import { getPathToShareDisplayedFolder } from '@/modules/drive/Toolbar/share/helpers'\n\ndescribe('getPathToShareDisplayedFolder', () => {\n  it('should return path to displayed folder share modal', () => {\n    expect(getPathToShareDisplayedFolder('/path/to/folder/123')).toBe(\n      '/path/to/folder/123/share'\n    )\n  })\n\n  it('should return correct path if pathname ends with /', () => {\n    expect(getPathToShareDisplayedFolder('/path/to/folder/123/')).toBe(\n      '/path/to/folder/123/share'\n    )\n  })\n})\n"
  },
  {
    "path": "src/modules/drive/helpers.ts",
    "content": "import { makeStyles } from 'cozy-ui/transpiled/react/styles'\n\n/* eslint-disable  */\nexport const useFabStyles = makeStyles(() => ({\n  root: {\n    position: 'fixed',\n    right: ({ right = '1rem' }) => right,\n    bottom: ({ bottom = '1rem' }) => bottom\n  }\n}))\n/* eslint-enable */\n\ninterface ErrorWithMessage {\n  message: string\n}\n\nconst isErrorWithMessage = (error: unknown): error is ErrorWithMessage => {\n  return (\n    typeof error === 'object' &&\n    error !== null &&\n    'message' in error &&\n    typeof (error as Record<string, unknown>).message === 'string'\n  )\n}\n\nconst toErrorWithMessage = (maybeError: unknown): ErrorWithMessage => {\n  if (isErrorWithMessage(maybeError)) return maybeError\n\n  try {\n    return new Error(JSON.stringify(maybeError))\n  } catch {\n    // fallback in case there's an error stringifying the maybeError\n    // like with circular references for example.\n    return new Error(String(maybeError))\n  }\n}\n\nexport const getErrorMessage = (error: unknown): string => {\n  return toErrorWithMessage(error).message\n}\n\nexport const getBlobFromBase64 = (base64: string, mimeString: string): Blob => {\n  const byteString = atob(base64)\n  const byteNumbers = new Array(byteString.length)\n  for (let i = 0; i < byteString.length; i++) {\n    byteNumbers[i] = byteString.charCodeAt(i)\n  }\n  const byteArray = new Uint8Array(byteNumbers)\n\n  return new Blob([byteArray], { type: mimeString })\n}\n\nexport const getFileFromBase64 = (\n  base64: string,\n  filename: string,\n  mimeString: string\n): File => {\n  const blob = getBlobFromBase64(base64, mimeString)\n  return new File([blob], filename, { type: mimeString })\n}\n\nexport const getUniqueNameFromPrefix = (prefix: string): string => {\n  const timestamp = Date.now()\n  const randomString = Math.random().toString(36).substring(7)\n  const fileName = `${prefix}_${timestamp}_${randomString}.jpg`\n\n  return fileName\n}\n"
  },
  {
    "path": "src/modules/drive/rename.js",
    "content": "// constants\n\nconst START_RENAMING = 'START_RENAMING'\nconst ABORT_RENAMING = 'ABORT_RENAMING'\n\n// reducers\n\nconst initialState = { file: null, name: null }\nconst renameReducer = (state = initialState, action) => {\n  switch (action.type) {\n    case START_RENAMING:\n      return { ...state, file: action.file, name: action.file.name }\n    case ABORT_RENAMING:\n      return initialState\n    default:\n      return state\n  }\n}\nexport default renameReducer\n\n// selectors\n\nexport const isRenaming = state => state.rename !== initialState\nexport const getRenamingFile = state => state.rename.file\nexport const getUpdatedName = state => state.rename.name\n\n// action creators sync\n\nexport const startRenaming = file => ({ type: START_RENAMING, file })\nexport const abortRenaming = () => ({ type: ABORT_RENAMING })\n\n// action creators async\n\nexport const startRenamingAsync = file => async dispatch => {\n  await dispatch(startRenaming(file))\n}\n"
  },
  {
    "path": "src/modules/duplicate/components/DuplicateModal.tsx",
    "content": "import React, { FC, useState } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useClient, models } from 'cozy-client'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { OpenFolderButton } from '@/components/Button/OpenFolderButton'\nimport { FolderPicker } from '@/components/FolderPicker/FolderPicker'\nimport { File, FolderPickerEntry } from '@/components/FolderPicker/types'\nimport { ROOT_DIR_ID } from '@/constants/config'\nimport { useCancelable } from '@/modules/move/hooks/useCancelable'\nimport { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'\n\ninterface DuplicateModalProps {\n  entries: FolderPickerEntry[]\n  currentFolder: File\n  onClose: () => void | Promise<void>\n  showNextcloudFolder?: boolean\n  showSharedDriveFolder?: boolean\n  isPublic?: boolean\n}\n\nconst DuplicateModal: FC<DuplicateModalProps> = ({\n  entries,\n  currentFolder,\n  onClose,\n  showNextcloudFolder,\n  showSharedDriveFolder,\n  isPublic\n}) => {\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n  const { registerCancelable } = useCancelable()\n  const client = useClient()\n  const navigate = useNavigate()\n\n  const [isBusy, setBusy] = useState(false)\n\n  const handleConfirm = async (folder: File): Promise<void> => {\n    try {\n      setBusy(true)\n      await Promise.all(\n        entries.map(async entry => {\n          await registerCancelable(\n            models.file.copy(client, entry as Partial<File>, folder, {\n              driveId: entry.driveId\n            })\n          )\n        })\n      )\n\n      const isCopyingInsideNextcloud =\n        folder._type === 'io.cozy.remote.nextcloud.files'\n\n      if (isCopyingInsideNextcloud) {\n        refreshNextcloudQueries(folder)\n      }\n\n      showAlert({\n        message: t('DuplicateModal.success', {\n          smart_count: entries.length,\n          fileName: entries[0].name,\n          destinationName:\n            folder._id === ROOT_DIR_ID\n              ? t('breadcrumb.title_drive')\n              : folder.name\n        }),\n        severity: 'success',\n        action: <OpenFolderButton folder={folder} navigate={navigate} />\n      })\n    } catch (_e) {\n      showAlert({\n        message: t('DuplicateModal.error'),\n        severity: 'error'\n      })\n    } finally {\n      setBusy(false)\n      await onClose()\n    }\n  }\n\n  /**\n   * The content from nextcloud queries must be refreshed when coping files\n   * This is only a proxy to Nextcloud queries so we don't have real-time or mutations updates\n   */\n  const refreshNextcloudQueries = (folder: File): void => {\n    const queryId = computeNextcloudFolderQueryId({\n      sourceAccount: folder.cozyMetadata?.sourceAccount,\n      path: folder.path\n    })\n    void client?.resetQuery(queryId)\n  }\n\n  return (\n    <FolderPicker\n      showNextcloudFolder={showNextcloudFolder}\n      showSharedDriveFolder={showSharedDriveFolder}\n      currentFolder={currentFolder}\n      entries={entries}\n      // eslint-disable-next-line @typescript-eslint/no-misused-promises\n      onConfirm={handleConfirm}\n      onClose={onClose}\n      isBusy={isBusy}\n      slotProps={{\n        header: {\n          subTitle: t('DuplicateModal.subTitle')\n        },\n        footer: {\n          confirmLabel: t('DuplicateModal.confirmLabel')\n        }\n      }}\n      canPickEntriesParentFolder\n      isPublic={isPublic}\n    />\n  )\n}\n\nexport { DuplicateModal }\n"
  },
  {
    "path": "src/modules/filelist/AddFolder.jsx",
    "content": "import React from 'react'\nimport { useDispatch, useSelector } from 'react-redux'\n\nimport { useClient } from 'cozy-client'\nimport flag from 'cozy-flags'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'\nimport { AddFolderCard } from '@/modules/filelist/AddFolderCard'\nimport { AddFolderRow } from '@/modules/filelist/AddFolderRow'\nimport {\n  isTypingNewFolderName,\n  hideNewFolderInput\n} from '@/modules/filelist/duck'\nimport AddFolderRowVz from '@/modules/filelist/virtualized/AddFolderRow'\nimport { createFolder } from '@/modules/navigation/duck'\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\nexport const AddFolder = ({ visible, onSubmit, onAbort, extraColumns }) => {\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n  const { isMobile } = useBreakpoints()\n  const { viewType } = useViewSwitcherContext()\n\n  if (!visible) {\n    return null\n  }\n\n  const Comp =\n    viewType === 'grid'\n      ? AddFolderCard\n      : flag('drive.virtualization.enabled') && !isMobile\n        ? AddFolderRowVz\n        : AddFolderRow\n\n  return (\n    <Comp\n      onSubmit={name => onSubmit(name, showAlert, t)}\n      onAbort={accidental => onAbort(accidental, showAlert, t)}\n      extraColumns={extraColumns}\n    />\n  )\n}\n\nconst AddFolderWithState = ({\n  currentFolderId,\n  driveId,\n  extraColumns,\n  afterSubmit,\n  afterAbort,\n  addItems\n}) => {\n  const client = useClient()\n  const dispatch = useDispatch()\n  const visible = useSelector(isTypingNewFolderName)\n\n  const onSubmit = (name, showAlert, t) =>\n    dispatch(async dispatch =>\n      dispatch(\n        createFolder(\n          client,\n          name,\n          currentFolderId,\n          { showAlert, t },\n          driveId,\n          addItems\n        )\n      ).then(() => {\n        afterSubmit?.() // eslint-disable-line promise/always-return\n      })\n    )\n\n  const onAbort = (accidental, showAlert, t) => {\n    if (accidental) {\n      showAlert({\n        message: t('alert.folder_abort'),\n        severity: 'secondary',\n        noClickAway: true\n      })\n    }\n    afterAbort?.()\n  }\n\n  return (\n    <AddFolder\n      visible={visible}\n      extraColumns={extraColumns}\n      onSubmit={onSubmit}\n      onAbort={onAbort}\n    />\n  )\n}\n\nconst AddFolderWithAfter = ({ refreshFolderContent, ...props }) => {\n  const dispatch = useDispatch()\n  const { addItems } = useNewItemHighlightContext()\n\n  const handleAfterSubmit = () => {\n    if (refreshFolderContent) {\n      refreshFolderContent()\n    }\n    dispatch(hideNewFolderInput())\n  }\n\n  const handleAfterAbort = () => {\n    dispatch(hideNewFolderInput())\n  }\n\n  return (\n    <AddFolderWithState\n      afterSubmit={handleAfterSubmit}\n      afterAbort={handleAfterAbort}\n      addItems={addItems}\n      {...props}\n    />\n  )\n}\n\nexport default AddFolderWithAfter\n"
  },
  {
    "path": "src/modules/filelist/AddFolder.spec.jsx",
    "content": "import { render, fireEvent, waitFor } from '@testing-library/react'\nimport React from 'react'\n\nimport { AddFolder } from './AddFolder'\nimport AppLike from 'test/components/AppLike'\nimport { setupStoreAndClient } from 'test/setup'\n\njest.mock('modules/navigation/duck/actions', () => ({\n  createFolder: jest.fn(() => async () => {})\n}))\n\njest.mock('lib/logger', () => ({\n  error: jest.fn()\n}))\n\njest.mock('cozy-flags', () => jest.fn())\njest.mock('cozy-keys-lib', () => ({\n  withVaultClient: jest.fn().mockReturnValue({}),\n  useVaultClient: jest.fn(),\n  WebVaultClient: jest.fn().mockReturnValue({})\n}))\n\ndescribe('AddFolder', () => {\n  const setup = () => {\n    const { client, store } = setupStoreAndClient({})\n\n    const onSubmit = jest.fn()\n\n    const container = render(\n      <AppLike client={client} store={store}>\n        <AddFolder visible onSubmit={onSubmit} />\n      </AppLike>\n    )\n    return { container, onSubmit }\n  }\n\n  it('should call onSubmit with folder name', async () => {\n    const { container, onSubmit } = setup()\n\n    const input = await container.findByRole('textbox')\n    await waitFor(async () => {\n      fireEvent.change(input, { target: { value: 'Mes photos de chat' } })\n      input.blur()\n    })\n\n    expect(onSubmit).toHaveBeenCalledWith(\n      'Mes photos de chat',\n      expect.anything(),\n      expect.anything()\n    )\n  })\n})\n"
  },
  {
    "path": "src/modules/filelist/AddFolderCard.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\n\nimport styles from '@/styles/filelist.styl'\n\nimport FilenameInput from '@/modules/filelist/FilenameInput'\nimport FileThumbnail from '@/modules/filelist/icons/FileThumbnail'\n\nconst AddFolderCard = ({ onSubmit, onAbort }) => {\n  return (\n    <div className={cx(styles['fil-content-column'])}>\n      <div\n        className={cx(\n          styles['fil-content-cell'],\n          styles['fil-file-thumbnail'],\n          styles['fil-content-grid-view']\n        )}\n      >\n        <FileThumbnail file={{ type: 'directory' }} size={96} />\n      </div>\n      <FilenameInput onSubmit={onSubmit} onAbort={onAbort} />\n    </div>\n  )\n}\n\nexport { AddFolderCard }\n"
  },
  {
    "path": "src/modules/filelist/AddFolderRow.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\n\nimport { TableRow, TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport styles from '@/styles/filelist.styl'\n\nimport FilenameInput from '@/modules/filelist/FilenameInput'\nimport { Empty as EmptyCell, LastUpdate } from '@/modules/filelist/cells'\nimport FileThumbnail from '@/modules/filelist/icons/FileThumbnail'\n\nconst AddFolderRow = ({ onSubmit, onAbort, extraColumns }) => {\n  const { isMobile } = useBreakpoints()\n\n  return (\n    <TableRow className={styles['fil-content-row']}>\n      <TableCell\n        className={cx(\n          styles['fil-content-cell'],\n          styles['fil-content-file-select']\n        )}\n      />\n      <TableCell\n        className={cx(\n          styles['fil-content-cell'],\n          styles['fil-file-thumbnail'],\n          {\n            'u-pl-0': !isMobile\n          }\n        )}\n      >\n        <FileThumbnail file={{ type: 'directory' }} />\n      </TableCell>\n      <TableCell\n        className={cx(styles['fil-content-cell'], styles['fil-content-file'])}\n      >\n        <FilenameInput onSubmit={onSubmit} onAbort={onAbort} />\n      </TableCell>\n      {!isMobile && (\n        <>\n          <LastUpdate />\n          <EmptyCell className={styles['fil-content-size']} />\n          {extraColumns &&\n            extraColumns.map(column => (\n              <EmptyCell\n                key={column.label}\n                className={styles['fil-content-narrow']}\n              />\n            ))}\n          <EmptyCell className={styles['fil-content-status']} />\n        </>\n      )}\n      <TableCell\n        className={cx(\n          styles['fil-content-cell'],\n          styles['fil-content-file-action']\n        )}\n      />\n    </TableRow>\n  )\n}\n\nexport { AddFolderRow }\n"
  },
  {
    "path": "src/modules/filelist/File.jsx",
    "content": "import cx from 'classnames'\nimport { filesize } from 'filesize'\nimport get from 'lodash/get'\nimport PropTypes from 'prop-types'\nimport React, { useState, useRef } from 'react'\nimport { useSelector } from 'react-redux'\n\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport Box from 'cozy-ui/transpiled/react/Box'\nimport { TableRow, TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport {\n  SelectBox,\n  FileName,\n  LastUpdate,\n  Size,\n  Status,\n  FileAction,\n  SharingShortcutBadge\n} from './cells'\n\nimport styles from '@/styles/filelist.styl'\n\nimport { useClipboardContext } from '@/contexts/ClipboardProvider'\nimport { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'\nimport { ActionMenuWithHeader } from '@/modules/actionmenu/ActionMenuWithHeader'\nimport { getContextMenuActions } from '@/modules/actions/helpers'\nimport { extraColumnsPropTypes } from '@/modules/certifications'\nimport {\n  isRenaming as isRenamingReducer,\n  getRenamingFile\n} from '@/modules/drive/rename'\nimport FileOpener from '@/modules/filelist/FileOpener'\nimport FileThumbnail from '@/modules/filelist/icons/FileThumbnail'\nimport { useFormattedUpdatedAt } from '@/modules/filelist/useFormattedUpdatedAt'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\n\nconst FileWrapper = ({ children, viewType, className, onContextMenu }) =>\n  viewType === 'list' ? (\n    <TableRow className={className} onContextMenu={onContextMenu}>\n      {children}\n    </TableRow>\n  ) : (\n    <Box\n      display=\"block\"\n      border={1}\n      borderColor=\"var(--dividerColor)\"\n      borderRadius={8}\n      padding={2}\n      className={className}\n      onContextMenu={onContextMenu}\n    >\n      {children}\n    </Box>\n  )\n\nconst ThumbnailWrapper = ({ children, viewType, className }) =>\n  viewType === 'list' ? (\n    <TableCell className={className}>{children}</TableCell>\n  ) : (\n    <div className={className}>{children}</div>\n  )\n\nconst File = ({\n  t,\n  attributes,\n  actions,\n  isRenaming,\n  withSelectionCheckbox,\n  withFilePath,\n  disabled,\n  styleDisabled,\n  refreshFolderContent,\n  isInSyncFromSharing,\n  extraColumns,\n  breakpoints: { isMobile },\n  disableSelection = false,\n  canInteractWith,\n  onContextMenu,\n  onToggleSelect\n}) => {\n  const { viewType } = useViewSwitcherContext()\n\n  const [actionMenuVisible, setActionMenuVisible] = useState(false)\n  const filerowMenuToggleRef = useRef()\n  const { toggleSelectedItem, isItemSelected } = useSelectionContext()\n\n  const { isItemCut } = useClipboardContext()\n\n  const toggleActionMenu = () => {\n    if (actionMenuVisible) return hideActionMenu()\n    else showActionMenu()\n  }\n  const showActionMenu = () => {\n    setActionMenuVisible(true)\n  }\n\n  const hideActionMenu = () => {\n    setActionMenuVisible(false)\n  }\n\n  const toggle = e => {\n    toggleSelectedItem(attributes)\n    onToggleSelect?.(attributes?._id, e)\n  }\n\n  const isRowDisabledOrInSyncFromSharing = disabled || isInSyncFromSharing\n  const isCut = isItemCut(attributes._id)\n\n  const selected = isItemSelected(attributes._id)\n\n  const filContentRowSelected = cx(styles['fil-content-row'], {\n    [styles['fil-content-row-selected']]: selected,\n    [styles['fil-content-row-actioned']]: actionMenuVisible,\n    [styles['fil-content-row-disabled']]: styleDisabled || isCut\n  })\n\n  const filContentColumnSelected = cx(styles['fil-content-column'], {\n    [styles['fil-content-column-selected']]: selected,\n    [styles['fil-content-column-actioned']]: actionMenuVisible,\n    [styles['fil-content-column-disabled']]: styleDisabled || isCut\n  })\n\n  const formattedSize =\n    !isDirectory(attributes) && attributes.size\n      ? filesize(attributes.size, { base: 10 })\n      : undefined\n\n  const updatedAt = attributes.updated_at || attributes.created_at\n  const formattedUpdatedAt = useFormattedUpdatedAt(updatedAt)\n\n  // We don't allow any action on shared drives and trash\n  // because they are magic folder created by the stack\n  let canInteractWithFile =\n    attributes._id &&\n    attributes._id !== 'io.cozy.files.shared-drives-dir' &&\n    !attributes._id.endsWith('.trash-dir')\n  if (typeof canInteractWith === 'function') {\n    canInteractWithFile &&= canInteractWith(attributes)\n  }\n\n  const contextMenuActions = getContextMenuActions(actions)\n\n  return (\n    <FileWrapper\n      viewType={viewType}\n      className={\n        viewType === 'list' ? filContentRowSelected : filContentColumnSelected\n      }\n      onContextMenu={onContextMenu}\n    >\n      <SelectBox\n        viewType={viewType}\n        withSelectionCheckbox={\n          withSelectionCheckbox && contextMenuActions?.length > 0\n        }\n        selected={selected}\n        onClick={toggle}\n        disabled={\n          !canInteractWithFile ||\n          isRowDisabledOrInSyncFromSharing ||\n          disableSelection ||\n          isCut\n        }\n      />\n      <FileOpener\n        file={attributes}\n        disabled={\n          isRowDisabledOrInSyncFromSharing || isCut || actionMenuVisible\n        }\n        toggle={toggle}\n        isRenaming={isRenaming}\n        onInteractWithFile={onToggleSelect}\n      >\n        <ThumbnailWrapper\n          viewType={viewType}\n          className={cx(\n            styles['fil-content-cell'],\n            styles['fil-file-thumbnail'],\n            {\n              'u-pl-0': !isMobile,\n              [styles['fil-content-grid-view']]: viewType === 'grid',\n              'u-ml-half':\n                !canInteractWithFile ||\n                isRowDisabledOrInSyncFromSharing ||\n                disableSelection\n            }\n          )}\n        >\n          <FileThumbnail\n            file={attributes}\n            size={viewType === 'grid' ? 96 : undefined}\n            isInSyncFromSharing={isInSyncFromSharing}\n            showSharedBadge={isMobile}\n            componentsProps={{\n              sharedBadge: {\n                className:\n                  styles[\n                    `fil-content-shared${viewType === 'grid' ? '-grid' : ''}`\n                  ]\n              }\n            }}\n          />\n          {viewType === 'grid' && (\n            <Status\n              file={attributes}\n              disabled={isRowDisabledOrInSyncFromSharing}\n              isInSyncFromSharing={isInSyncFromSharing}\n            />\n          )}\n        </ThumbnailWrapper>\n        <FileName\n          attributes={attributes}\n          isRenaming={isRenaming}\n          interactive={!isRowDisabledOrInSyncFromSharing}\n          withFilePath={withFilePath}\n          isMobile={isMobile}\n          formattedSize={formattedSize}\n          formattedUpdatedAt={formattedUpdatedAt}\n          refreshFolderContent={refreshFolderContent}\n          isInSyncFromSharing={isInSyncFromSharing}\n        />\n        {viewType === 'list' && (\n          <>\n            <LastUpdate\n              date={updatedAt}\n              formatted={\n                isDirectory(attributes) ? undefined : formattedUpdatedAt\n              }\n            />\n            <Size filesize={formattedSize} />\n            {extraColumns &&\n              extraColumns.map(column => (\n                <column.CellComponent key={column.label} file={attributes} />\n              ))}\n            <Status\n              file={attributes}\n              disabled={isRowDisabledOrInSyncFromSharing}\n              isInSyncFromSharing={isInSyncFromSharing}\n            />\n          </>\n        )}\n        <SharingShortcutBadge file={attributes} />\n      </FileOpener>\n      {contextMenuActions && canInteractWithFile && (\n        <FileAction\n          t={t}\n          ref={filerowMenuToggleRef}\n          disabled={isRowDisabledOrInSyncFromSharing || isCut}\n          isInSyncFromSharing={isInSyncFromSharing}\n          onClick={() => {\n            toggleActionMenu()\n          }}\n        />\n      )}\n      {contextMenuActions && actionMenuVisible && (\n        <ActionMenuWithHeader\n          file={attributes}\n          anchorElRef={filerowMenuToggleRef}\n          actions={contextMenuActions}\n          onClose={hideActionMenu}\n        />\n      )}\n    </FileWrapper>\n  )\n}\n\nFile.propTypes = {\n  t: PropTypes.func,\n  attributes: PropTypes.object.isRequired,\n  actions: PropTypes.array,\n  isRenaming: PropTypes.bool,\n  withSelectionCheckbox: PropTypes.bool.isRequired,\n  withFilePath: PropTypes.bool,\n  /** Disables row actions */\n  disabled: PropTypes.bool,\n  /** Apply disabled style on row */\n  styleDisabled: PropTypes.bool,\n  breakpoints: PropTypes.object.isRequired,\n  refreshFolderContent: PropTypes.func,\n  isInSyncFromSharing: PropTypes.bool,\n  extraColumns: extraColumnsPropTypes,\n  /** Disables the ability to select a file */\n  disableSelection: PropTypes.bool,\n  onToggleSelect: PropTypes.func\n}\n\nexport const DumbFile = props => {\n  const { t } = useI18n()\n  const breakpoints = useBreakpoints()\n\n  return <File t={t} breakpoints={breakpoints} {...props} />\n}\n\nexport const FileWithSelection = props => {\n  const isRenaming = useSelector(\n    state =>\n      isRenamingReducer(state) &&\n      get(getRenamingFile(state), '_id') === props.attributes._id\n  )\n\n  return <DumbFile isRenaming={isRenaming} {...props} />\n}\n"
  },
  {
    "path": "src/modules/filelist/File.spec.jsx",
    "content": "'use strict'\n\nimport { render, fireEvent, waitFor } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\nimport { useSharingContext } from 'cozy-sharing'\n\nimport { DumbFile } from './File'\nimport AppLike from 'test/components/AppLike'\nimport { folder, actionsMenu } from 'test/data'\n\njest.mock('cozy-sharing', () => ({\n  ...jest.requireActual('cozy-sharing'),\n  useSharingContext: jest.fn()\n}))\n\nuseSharingContext.mockReturnValue({ byDocId: [] })\n\nconst client = createMockClient({\n  clientOptions: {\n    uri: 'http://cozy.localhost:8080/'\n  },\n  clientFunctions: {\n    getInstanceOptions: () => ({\n      subdomain: 'nested'\n    })\n  }\n})\n\nconst setup = ({\n  attributes = folder,\n  actions = actionsMenu,\n  selected = false,\n  withSelectionCheckbox = true,\n  selectionModeActive = false,\n  onFileOpen = jest.fn(),\n  onCheckboxToggle = jest.fn(),\n  isInSyncFromSharing = false,\n  disableSelection = false\n} = {}) => {\n  const root = render(\n    <AppLike client={client}>\n      <DumbFile\n        attributes={attributes}\n        actions={actions}\n        selected={selected}\n        withSelectionCheckbox={withSelectionCheckbox}\n        selectionModeActive={selectionModeActive}\n        onFileOpen={onFileOpen}\n        onCheckboxToggle={onCheckboxToggle}\n        isInSyncFromSharing={isInSyncFromSharing}\n        disableSelection={disableSelection}\n      />\n    </AppLike>\n  )\n  return { root }\n}\n\ndescribe('File', () => {\n  describe('default behavior', () => {\n    it('should show a select box', () => {\n      const { root } = setup()\n      const { getByRole } = root\n\n      expect(getByRole('checkbox'))\n    })\n\n    it('should not show spinner', () => {\n      const { root } = setup()\n      const { queryByTestId } = root\n\n      expect(queryByTestId('fil-file-thumbnail--spinner')).toBeNull()\n    })\n\n    it('should show actions menu when clicking the actionsMenu button', async () => {\n      // TODO : Fix https://github.com/cozy/cozy-drive/issues/2913\n      jest.spyOn(console, 'warn').mockImplementation()\n      let root\n      await waitFor(async () => {\n        root = setup().root\n      })\n      const { getByRole, findByText } = root\n\n      await waitFor(async () => {\n        fireEvent.click(getByRole('button', { name: 'More' }))\n      })\n      expect(await findByText('ActionsMenuItem'))\n    })\n\n    it('should not show select all in actions menu when clicking the actionsMenu button', async () => {\n      let root\n      await waitFor(async () => {\n        root = setup().root\n      })\n      const { getByRole, queryByText } = root\n\n      await waitFor(async () => {\n        fireEvent.click(getByRole('button', { name: 'More' }))\n      })\n      // \"SelectAllMenuItem\" should be filtered out by File before rendering\n      expect(queryByText('SelectAllMenuItem')).toBeNull()\n    })\n\n    it('should show select all in selection bar', () => {\n      const { root } = setup()\n      const { getByRole, queryByText } = root\n\n      const checkbox = getByRole('checkbox')\n\n      fireEvent.click(checkbox)\n\n      expect(queryByText('SelectAllMenuItem'))\n    })\n  })\n\n  describe('In sync from sharing behavior', () => {\n    it('should show spinner', () => {\n      const { root } = setup({ isInSyncFromSharing: true })\n      const { getByTestId } = root\n\n      expect(getByTestId('fil-file-thumbnail--spinner'))\n    })\n\n    it('should not show actions menu when clicking the actionsMenu button', async () => {\n      let root\n      await waitFor(async () => {\n        root = setup({ isInSyncFromSharing: true }).root\n      })\n      const { getByRole, queryByText } = root\n\n      await waitFor(async () => {\n        fireEvent.click(getByRole('button', { name: 'More' }))\n      })\n      expect(queryByText('ActionsMenuItem')).toBeNull()\n    })\n\n    it('should not show a select box when syncing', () => {\n      const { root } = setup({ isInSyncFromSharing: true })\n      const { queryByRole } = root\n\n      expect(queryByRole('checkbox')).toBeNull()\n    })\n\n    it('should not show a select box when selection disabled', () => {\n      const { root } = setup({ disableSelection: true })\n      const { queryByRole } = root\n\n      expect(queryByRole('checkbox')).toBeNull()\n    })\n\n    it('should not have clickable sharing avatars', () => {\n      const { root } = setup({ isInSyncFromSharing: true })\n      const { getByTestId } = root\n\n      expect(getByTestId('fil-content-sharestatus--noAvatar'))\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/filelist/FileList.jsx",
    "content": "import cx from 'classnames'\nimport React, { forwardRef } from 'react'\n\nimport { Table } from 'cozy-ui/transpiled/react/deprecated/Table'\n\nimport styles from '@/styles/filelist.styl'\n\nexport const FileList = forwardRef(({ children }, ref) => {\n  return (\n    <Table\n      ref={ref}\n      className={cx('u-ov-auto', styles['fil-file-list-container'])}\n      role=\"table\"\n      tabIndex={0}\n    >\n      {children}\n    </Table>\n  )\n})\n\nFileList.displayName = 'FileList'\n"
  },
  {
    "path": "src/modules/filelist/FileListBody.jsx",
    "content": "import cx from 'classnames'\nimport React, { useContext } from 'react'\n\nimport { TableBody } from 'cozy-ui/transpiled/react/deprecated/Table'\n\nimport styles from '@/styles/filelist.styl'\n\nimport { FabContext } from '@/lib/FabProvider'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\n\n/**\n * Renders the body of the file list.\n *\n * @component\n * @param {Object} [props] - The component props.\n * @param {string} [props.className] - The CSS class name for the component.\n * @param {ReactNode} props.children - The child elements to be rendered inside the component.\n * @returns {JSX.Element} The rendered component.\n */\nconst FileListBody = ({ className, children }) => {\n  const { isSelectionBarVisible } = useSelectionContext()\n  const { isFabDisplayed } = useContext(FabContext)\n\n  return (\n    <TableBody\n      data-testid=\"fil-content-body\"\n      className={cx(className, styles['fil-content-body'], {\n        [styles['fil-content-body--selectable']]: isSelectionBarVisible,\n        [styles['fil-content-body--withFabActive']]: isFabDisplayed\n      })}\n    >\n      {children}\n    </TableBody>\n  )\n}\n\nexport default FileListBody\n"
  },
  {
    "path": "src/modules/filelist/FileListHeader.jsx",
    "content": "import React from 'react'\n\nimport { FileListHeaderDesktop } from '@/modules/filelist/FileListHeaderDesktop'\nimport { FileListHeaderMobile } from '@/modules/filelist/FileListHeaderMobile'\n\n/**\n * @typedef {Object} Props\n * @property {string|null} props.folderId - The ID of the folder.\n * @property {boolean} props.canSort - Indicates whether sorting is allowed.\n * @property {Sort} [props.sort] - The current sorting option.\n * @property {Function} [props.onFolderSort] - The function to handle folder sorting.\n * @property {Function} [props.toggleViewType] - The function to toggle the view to list or grid.\n * @property {Array} [props.extraColumns] - An array of extra columns.\n */\n\n/**\n * Renders the header component for the file list.\n * The responsive design is handled by CSS media queries.\n * @param {Props} props - The component props.\n * @returns {JSX.Element} The rendered component.\n */\nconst FileListHeader = props => {\n  return (\n    <>\n      <FileListHeaderDesktop {...props} />\n      <FileListHeaderMobile {...props} />\n    </>\n  )\n}\n\nexport { FileListHeader }\n"
  },
  {
    "path": "src/modules/filelist/FileListHeaderDesktop.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\n\nimport {\n  TableHead,\n  TableHeader,\n  TableRow\n} from 'cozy-ui/transpiled/react/deprecated/Table'\nimport { useI18n } from 'twake-i18n'\n\nimport HeaderCell from './HeaderCell'\n\nimport styles from '@/styles/filelist.styl'\n\nimport { SORTABLE_ATTRIBUTES } from '@/config/sort'\n\nconst FileListHeaderDesktop = ({\n  folderId,\n  canSort,\n  sort,\n  onFolderSort,\n  extraColumns,\n  viewType\n}) => {\n  const { t } = useI18n()\n\n  return (\n    <TableHead\n      className={cx(styles['fil-content-head'], {\n        [styles['fil-content-head-grid-view']]: viewType === 'grid'\n      })}\n    >\n      <TableRow className={styles['fil-content-row-head']}>\n        <TableHeader\n          className={cx(\n            styles['fil-content-header'],\n            styles['fil-content-file-select'],\n            styles['fil-content-header--capitalize']\n          )}\n        />\n        {SORTABLE_ATTRIBUTES.map(\n          ({ label, attr, css, defaultOrder }, index) => {\n            if (!canSort) {\n              return (\n                <HeaderCell\n                  key={index}\n                  label={label}\n                  css={css}\n                  className={styles['fil-content-header--capitalize']}\n                />\n              )\n            }\n            const isActive = sort && sort.attribute === attr\n            return (\n              <HeaderCell\n                key={`key_cell_${index}`}\n                label={label}\n                attr={attr}\n                css={css}\n                defaultOrder={defaultOrder}\n                order={isActive ? sort.order : null}\n                onSort={(attr, order) => onFolderSort(folderId, attr, order)}\n                className={styles['fil-content-header--capitalize']}\n              />\n            )\n          }\n        )}\n        <TableHeader\n          className={cx(\n            styles['fil-content-header'],\n            styles['fil-content-size'],\n            styles['fil-content-header--capitalize']\n          )}\n        >\n          {t('table.head_size')}\n        </TableHeader>\n        {extraColumns &&\n          extraColumns.map(column => (\n            <column.HeaderComponent key={column.label} />\n          ))}\n        <TableHeader\n          className={cx(\n            styles['fil-content-header'],\n            styles['fil-content-header-status'],\n            styles['fil-content-header--capitalize']\n          )}\n        >\n          {t('table.head_status')}\n        </TableHeader>\n        <TableHeader\n          className={cx(\n            styles['fil-content-header'],\n            styles['fil-content-header-sharing-shortcut'],\n            styles['fil-content-header--capitalize']\n          )}\n        />\n        <TableHeader\n          className={cx(\n            styles['fil-content-header'],\n            styles['fil-content-header-action'],\n            styles['fil-content-header--capitalize']\n          )}\n        >\n          {/** Empty header cell for actions column */}\n        </TableHeader>\n      </TableRow>\n    </TableHead>\n  )\n}\n\nexport { FileListHeaderDesktop }\n"
  },
  {
    "path": "src/modules/filelist/FileListHeaderMobile.jsx",
    "content": "import cx from 'classnames'\nimport React, { useState, useCallback } from 'react'\n\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ListIcon from 'cozy-ui/transpiled/react/Icons/List'\nimport ListMinIcon from 'cozy-ui/transpiled/react/Icons/ListMin'\nimport {\n  TableHead,\n  TableHeader,\n  TableRow\n} from 'cozy-ui/transpiled/react/deprecated/Table'\nimport { useI18n } from 'twake-i18n'\n\nimport MobileSortMenu from './MobileSortMenu'\n\nimport styles from '@/styles/filelist.styl'\n\nimport { useCurrentFolderId } from '@/hooks'\n\nconst FileListHeaderMobile = ({\n  canSort,\n  sort,\n  onFolderSort,\n  viewType,\n  switchViewType\n}) => {\n  const { t } = useI18n()\n  const [isShowingSortMenu, setIsShowingSortMenu] = useState(false)\n\n  const folderId = useCurrentFolderId()\n\n  const showSortMenu = useCallback(\n    () => setIsShowingSortMenu(true),\n    [setIsShowingSortMenu]\n  )\n  const hideSortMenu = useCallback(\n    () => setIsShowingSortMenu(false),\n    [setIsShowingSortMenu]\n  )\n\n  return (\n    <TableHead className={styles['fil-content-mobile-head']}>\n      <TableRow className={styles['fil-content-row-head']}>\n        {canSort ? (\n          <TableHeader\n            onClick={showSortMenu}\n            className={cx(\n              styles['fil-content-mobile-header'],\n              styles['fil-content-header--capitalize'],\n              {\n                [styles['fil-content-header-sortasc']]: sort.order === 'asc',\n                [styles['fil-content-header-sortdesc']]: sort.order === 'desc'\n              }\n            )}\n          >\n            {t(`table.mobile.head_${sort.attribute}_${sort.order}`)}\n          </TableHeader>\n        ) : (\n          <div className=\"u-flex-auto\" /> // to keep the viewType switch to the right side\n        )}\n\n        {isShowingSortMenu && (\n          <MobileSortMenu\n            t={t}\n            sort={sort}\n            onClose={hideSortMenu}\n            onSort={(attr, order) => onFolderSort(folderId, attr, order)}\n          />\n        )}\n        <TableHeader\n          className={cx(\n            styles['fil-content-mobile-header'],\n            styles['fil-content-header-action'],\n            styles['fil-content-header--capitalize']\n          )}\n        >\n          <Button\n            variant=\"text\"\n            onClick={() => {\n              switchViewType(viewType === 'list' ? 'grid' : 'list')\n            }}\n            label={\n              <Icon\n                icon={viewType === 'list' ? ListMinIcon : ListIcon}\n                size={17}\n              />\n            }\n          />\n        </TableHeader>\n      </TableRow>\n    </TableHead>\n  )\n}\n\nexport { FileListHeaderMobile }\n"
  },
  {
    "path": "src/modules/filelist/FileListRowsPlaceholder.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport FilePlaceholder from '@/modules/filelist/FilePlaceholder'\n\nconst FileListPlaceholder = ({ rows }) => (\n  <div>\n    {[...new Array(rows)].map((value, index) => (\n      <FilePlaceholder index={index} key={`key_file_placeholder_${index}`} />\n    ))}\n  </div>\n)\n\nFileListPlaceholder.propTypes = {\n  rows: PropTypes.number\n}\n\nFileListPlaceholder.defaultProps = {\n  rows: 8\n}\n\nexport default FileListPlaceholder\n"
  },
  {
    "path": "src/modules/filelist/FileOpener.jsx",
    "content": "import React, { useRef } from 'react'\n\nimport styles from './fileopener.styl'\n\nimport { useLongPress } from '@/hooks/useOnLongPress'\nimport { FileLink } from '@/modules/navigation/components/FileLink'\nimport { useFileLink } from '@/modules/navigation/hooks/useFileLink'\n\nconst FileOpener = ({\n  file,\n  toggle,\n  disabled,\n  isRenaming,\n  onInteractWithFile,\n  children\n}) => {\n  const rowRef = useRef()\n  const { link, openLink } = useFileLink(file)\n  const handlers = useLongPress({\n    file,\n    disabled,\n    isRenaming,\n    openLink,\n    toggle,\n    onInteractWithFile\n  })\n\n  if (isRenaming) {\n    return children\n  }\n\n  return (\n    <FileLink\n      ref={rowRef}\n      link={link}\n      className={`${styles['file-opener']} ${styles['file-opener__a']}`}\n      {...handlers}\n    >\n      {children}\n    </FileLink>\n  )\n}\n\nexport default FileOpener\n"
  },
  {
    "path": "src/modules/filelist/FileOpener.spec.jsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\n\nimport FileOpener from './FileOpener'\nimport AppLike from 'test/components/AppLike'\nimport { generateFile } from 'test/generate'\n\nimport { useFileLink } from '@/modules/navigation/hooks/useFileLink'\n\njest.mock('cozy-client/dist/models/file', () => ({\n  ...jest.requireActual('cozy-client/dist/models/file'),\n  shouldBeOpenedByOnlyOffice: jest.fn()\n}))\n\njest.mock('modules/navigation/hooks/useFileLink', () => ({\n  useFileLink: jest.fn()\n}))\n\ndescribe('FileOpener component', () => {\n  const client = createMockClient({})\n  const file = generateFile({ i: 1 })\n\n  const setup = ({ file, linkApp = 'drive' }) => {\n    useFileLink.mockReturnValue({\n      link: {\n        app: linkApp,\n        to: '/path/to/file',\n        href: 'http://cozy.tools:8080/files/123'\n      }\n    })\n\n    render(\n      <AppLike client={client}>\n        <FileOpener file={file}>{file.name}</FileOpener>\n      </AppLike>\n    )\n  }\n\n  afterEach(() => {\n    jest.clearAllMocks()\n  })\n\n  it('renders a Link when link.app is drive', async () => {\n    setup({ file, linkApp: 'drive' })\n    const linkElement = await screen.findByText(file.name)\n    expect(linkElement).toBeInTheDocument()\n    expect(linkElement.getAttribute('href')).toBe('#/path/to/file')\n  })\n\n  it('renders an anchor when link.app is not drive', async () => {\n    setup({ file, linkApp: 'other-app' })\n    const anchorElement = await screen.findByText(file.name)\n    expect(anchorElement).toBeInTheDocument()\n    expect(anchorElement.getAttribute('href')).toBe(\n      'http://cozy.tools:8080/files/123'\n    )\n  })\n})\n"
  },
  {
    "path": "src/modules/filelist/FilePlaceholder.jsx",
    "content": "import cx from 'classnames'\nimport PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { TableRow, TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport withBreakpoints from 'cozy-ui/transpiled/react/helpers/withBreakpoints'\n\nimport styles from '@/styles/filelist.styl'\n\n// using a seeded PRNG to prevent re-renders from changing the results\nconst seededRandom = seed => {\n  const x = Math.sin(seed) * 10000\n  return x - Math.floor(x)\n}\nconst seededRandomBetween = (min, max, seed) =>\n  min + seededRandom(seed) * (max - min)\n\nconst PlaceholderBlock = ({ width }) => (\n  <div className={styles['fil-content-file-placeholder']} style={{ width }} />\n)\n\nPlaceholderBlock.propTypes = {\n  width: PropTypes.string\n}\n\nPlaceholderBlock.defaultProps = {\n  width: '100%'\n}\n\nconst FilePlaceholder = ({ index, breakpoints: { isMobile } }) => (\n  <TableRow className={styles['fil-content-row']}>\n    <TableCell\n      className={cx(\n        styles['fil-content-cell'],\n        styles['fil-content-file-select']\n      )}\n    />\n    <TableCell\n      className={cx(styles['fil-content-cell'], styles['fil-file-thumbnail'], {\n        'u-pl-0': !isMobile\n      })}\n    >\n      <PlaceholderBlock width=\"2rem\" />\n    </TableCell>\n    <TableCell\n      className={cx(styles['fil-content-cell'], styles['fil-content-file'])}\n    >\n      <PlaceholderBlock width={`${seededRandomBetween(3, 12, index)}rem`} />\n    </TableCell>\n    <TableCell\n      className={cx(styles['fil-content-cell'], styles['fil-content-date'])}\n    >\n      <PlaceholderBlock width=\"5rem\" />\n    </TableCell>\n    <TableCell\n      className={cx(styles['fil-content-cell'], styles['fil-content-size'])}\n    >\n      <PlaceholderBlock width={`${seededRandomBetween(3.75, 5, index)}rem`} />\n    </TableCell>\n    <TableCell\n      className={cx(styles['fil-content-cell'], styles['fil-content-status'])}\n    >\n      <PlaceholderBlock width=\"1.25rem\" />\n    </TableCell>\n  </TableRow>\n)\n\nFilePlaceholder.propTypes = {\n  index: PropTypes.number\n}\n\nFilePlaceholder.defaultProps = {\n  index: 1\n}\n\nexport default withBreakpoints()(FilePlaceholder)\n"
  },
  {
    "path": "src/modules/filelist/FilenameInput.jsx",
    "content": "import cx from 'classnames'\nimport React, { useState, useRef, useEffect, useCallback } from 'react'\n\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport { Dialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\nimport { translate } from 'twake-i18n'\n\nimport styles from '@/styles/filenameinput.styl'\n\nimport { CozyFile } from '@/models'\nimport { getCaretPositionFromPoint } from '@/modules/filelist/getCaretPositionFromPoint'\n\nconst ENTER_KEY = 13\nconst ESC_KEY = 27\n\nconst valueIsEmpty = value => value.toString() === ''\n\nconst FilenameInput = ({\n  name: initialName = '',\n  file,\n  onSubmit,\n  onAbort,\n  onChange,\n  t,\n  className,\n  style\n}) => {\n  const textInput = useRef()\n  const [value, setValue] = useState(initialName || '')\n  const [working, setWorking] = useState(false)\n  const [error, setError] = useState(false)\n  const [isModalOpened, setIsModalOpened] = useState(false)\n\n  // Use a ref for synchronous guard to prevent race conditions\n  // when Enter and blur fire in quick succession\n  const isSubmittingRef = useRef(false)\n  const isSavingRef = useRef(false)\n\n  const save = useCallback(async () => {\n    if (isSavingRef.current) return\n    isSavingRef.current = true\n    if (!onSubmit) {\n      setWorking(false)\n      isSubmittingRef.current = false\n      isSavingRef.current = false\n      return\n    }\n\n    try {\n      await onSubmit(value)\n    } catch (_e) {\n      setError(true)\n    } finally {\n      setWorking(false)\n      isSubmittingRef.current = false\n      isSavingRef.current = false\n    }\n  }, [onSubmit, value])\n\n  const abort = useCallback(\n    (accidental = false) => {\n      if (isModalOpened) {\n        setIsModalOpened(false)\n      }\n      onAbort && onAbort(accidental)\n      isSubmittingRef.current = false\n      setWorking(false)\n    },\n    [isModalOpened, onAbort]\n  )\n\n  const handleKeyDown = e => {\n    if (e.keyCode === ENTER_KEY) {\n      if (valueIsEmpty(value)) {\n        abort(true)\n      } else {\n        submit()\n      }\n    } else if (e.keyCode === ESC_KEY) {\n      abort()\n    }\n  }\n\n  const handleChange = e => {\n    const newValue = e.target.value\n    setValue(newValue)\n    onChange && onChange(newValue)\n  }\n\n  const handleBlur = () => {\n    if (valueIsEmpty(value)) {\n      abort(!!initialName)\n    } else {\n      submit()\n    }\n  }\n\n  const submit = () => {\n    // Use ref for synchronous guard - state updates are async\n    // so they don't prevent double submission in same event loop\n    if (isSubmittingRef.current) return\n\n    isSubmittingRef.current = true\n    setWorking(true)\n    setError(false)\n\n    if (!initialName) {\n      save()\n      return\n    }\n\n    if (file && !isDirectory(file)) {\n      const previousExtension = CozyFile.splitFilename({\n        name: initialName,\n        type: 'file'\n      }).extension\n      const newExtension = CozyFile.splitFilename({\n        name: value,\n        type: 'file'\n      }).extension\n      if (previousExtension !== newExtension) {\n        setIsModalOpened(true)\n      } else {\n        save()\n      }\n    } else {\n      save()\n    }\n  }\n\n  const shouldSetSelection = useRef(false)\n\n  const handleFocus = () => {\n    if (!initialName) return\n    shouldSetSelection.current = true\n  }\n\n  useEffect(() => {\n    if (!shouldSetSelection.current || !textInput.current) return\n    if (!initialName) return\n\n    const { filename } = CozyFile.splitFilename({\n      name: initialName,\n      type: 'file'\n    })\n\n    textInput.current.setSelectionRange(\n      0,\n      isDirectory(file) ? initialName.length : filename.length\n    )\n    shouldSetSelection.current = false\n  }, [initialName, file])\n\n  // Firefox does not natively reposition the cursor on click in this DOM context.\n  // Workaround: manually compute and apply the caret position on mouseup.\n  const handleMouseUp = useCallback(e => {\n    const input = textInput.current\n    if (!input || e.target !== input) return\n    const offset = getCaretPositionFromPoint(e.clientX, e.clientY)\n    if (offset !== null) {\n      input.setSelectionRange(offset, offset)\n    }\n  }, [])\n\n  return (\n    <div\n      data-testid=\"name-input\"\n      className={cx(styles['fil-file-name-input'], className)}\n      style={style}\n    >\n      <input\n        type=\"text\"\n        value={value}\n        ref={textInput}\n        disabled={working}\n        onChange={handleChange}\n        onFocus={handleFocus}\n        onBlur={handleBlur}\n        onKeyDown={handleKeyDown}\n        onMouseUp={handleMouseUp}\n        className={error ? styles['error'] : null}\n        autoFocus\n      />\n      {working && <Spinner />}\n      <Dialog\n        onClose={abort}\n        open={isModalOpened}\n        title={t('RenameModal.title')}\n        content={t('RenameModal.description')}\n        actions={\n          <>\n            <Button\n              variant=\"secondary\"\n              onClick={abort}\n              label={t('RenameModal.cancel')}\n            />\n            <Button\n              variant=\"primary\"\n              label={t('RenameModal.continue')}\n              onClick={save}\n            />\n          </>\n        }\n        actionsLayout=\"row\"\n      />\n    </div>\n  )\n}\n\nexport default translate()(FilenameInput)\n"
  },
  {
    "path": "src/modules/filelist/FilenameInput.spec.jsx",
    "content": "'use strict'\n\nimport '@testing-library/jest-dom'\nimport { render, fireEvent, screen, act } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\n\nimport FilenameInput from './FilenameInput'\nimport AppLike from 'test/components/AppLike'\n\ndescribe('FilenameInput', () => {\n  const client = createMockClient({\n    clientOptions: {\n      uri: 'http://cozy.localhost:8080/'\n    }\n  })\n\n  const setup = ({\n    name = '',\n    file = null,\n    onSubmit = jest.fn(),\n    onAbort = jest.fn(),\n    onChange = jest.fn()\n  } = {}) => {\n    const root = render(\n      <AppLike client={client}>\n        <FilenameInput\n          name={name}\n          file={file}\n          onSubmit={onSubmit}\n          onAbort={onAbort}\n          onChange={onChange}\n        />\n      </AppLike>\n    )\n    return { root, onSubmit, onAbort, onChange }\n  }\n\n  describe('handleKeyDown behavior', () => {\n    it('should call submit when ENTER_KEY is pressed with non-empty value', async () => {\n      const { onSubmit } = setup()\n      const input = screen.getByRole('textbox')\n\n      // Type some text\n      await act(async () => {\n        fireEvent.change(input, { target: { value: 'test-file' } })\n        fireEvent.keyDown(input, { keyCode: 13 })\n      })\n\n      expect(onSubmit).toHaveBeenCalledWith('test-file')\n    })\n\n    it('should call abort with accidental=true when ENTER_KEY is pressed with empty value', async () => {\n      const { onAbort } = setup()\n      const input = screen.getByRole('textbox')\n\n      // Press Enter with empty value\n      await act(async () => {\n        fireEvent.keyDown(input, { keyCode: 13 })\n      })\n\n      expect(onAbort).toHaveBeenCalledWith(true)\n    })\n\n    it('should call abort when ESC_KEY is pressed', async () => {\n      const { onAbort } = setup()\n      const input = screen.getByRole('textbox')\n\n      // Press Escape\n      await act(async () => {\n        fireEvent.keyDown(input, { keyCode: 27 })\n      })\n\n      expect(onAbort).toHaveBeenCalled()\n    })\n  })\n\n  describe('handleBlur behavior', () => {\n    it('should call submit when blurred with non-empty value', async () => {\n      const { onSubmit } = setup()\n      const input = screen.getByRole('textbox')\n\n      // Type some text and blur\n      await act(async () => {\n        fireEvent.change(input, { target: { value: 'test-file' } })\n        fireEvent.blur(input)\n      })\n\n      expect(onSubmit).toHaveBeenCalledWith('test-file')\n    })\n\n    it('should call abort when blurred with empty value', async () => {\n      const { onAbort } = setup()\n      const input = screen.getByRole('textbox')\n\n      // Blur with empty value\n      await act(async () => {\n        fireEvent.blur(input)\n      })\n\n      expect(onAbort).toHaveBeenCalled()\n    })\n  })\n\n  describe('handleChange behavior', () => {\n    it('should update state and call onChange when input changes', async () => {\n      const { onChange } = setup()\n      const input = screen.getByRole('textbox')\n\n      await act(async () => {\n        fireEvent.change(input, { target: { value: 'new-value' } })\n      })\n\n      expect(onChange).toHaveBeenCalledWith('new-value')\n      expect(input.value).toBe('new-value')\n    })\n  })\n\n  describe('race condition fix verification', () => {\n    it('should not show unwanted notification for empty filename on ENTER_KEY', async () => {\n      const { onAbort } = setup()\n      const input = screen.getByRole('textbox')\n\n      // Simulate pressing Enter with empty value\n      await act(async () => {\n        fireEvent.keyDown(input, { keyCode: 13 })\n      })\n\n      // Should call abort with accidental=true, not show unwanted notification\n      expect(onAbort).toHaveBeenCalledWith(true)\n      expect(onAbort).toHaveBeenCalledTimes(1)\n    })\n\n    it('should handle blur correctly without race condition', async () => {\n      const { onSubmit, onAbort } = setup()\n      const input = screen.getByRole('textbox')\n\n      // Type some text and blur\n      await act(async () => {\n        fireEvent.change(input, { target: { value: 'valid-file' } })\n        fireEvent.blur(input)\n      })\n\n      // Should submit without any race condition issues\n      expect(onSubmit).toHaveBeenCalledWith('valid-file')\n      expect(onAbort).not.toHaveBeenCalled()\n    })\n  })\n\n  describe('edge cases', () => {\n    it('should handle whitespace-only value as non-empty', async () => {\n      const { onSubmit } = setup()\n      const input = screen.getByRole('textbox')\n\n      // Type whitespace and press Enter\n      await act(async () => {\n        fireEvent.change(input, { target: { value: '   ' } })\n        fireEvent.keyDown(input, { keyCode: 13 })\n      })\n\n      // Whitespace is considered non-empty by the component\n      expect(onSubmit).toHaveBeenCalledWith('   ')\n    })\n    it('should handle Enter followed by blur correctly', async () => {\n      const { onSubmit, onAbort } = setup()\n      const input = screen.getByRole('textbox')\n\n      // Type a value and press Enter\n      await act(async () => {\n        fireEvent.change(input, { target: { value: 'test-file' } })\n        fireEvent.keyDown(input, { keyCode: 13 })\n        fireEvent.blur(input)\n      })\n\n      // Should only submit once, not twice\n      expect(onSubmit).toHaveBeenCalledTimes(1)\n      expect(onSubmit).toHaveBeenCalledWith('test-file')\n      expect(onAbort).not.toHaveBeenCalled()\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/filelist/HeaderCell.jsx",
    "content": "import cx from 'classnames'\nimport React, { useCallback } from 'react'\n\nimport { TableHeader } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/filelist.styl'\n\nconst HeaderCell = ({\n  label,\n  css,\n  attr,\n  order = null,\n  className,\n  defaultOrder,\n  onSort\n}) => {\n  const { t } = useI18n()\n  const sortCallback = useCallback(\n    () =>\n      onSort &&\n      onSort(attr, order ? (order === 'asc' ? 'desc' : 'asc') : defaultOrder),\n    [onSort, attr, order, defaultOrder]\n  )\n  return (\n    <TableHeader\n      onClick={sortCallback}\n      className={cx(\n        styles['fil-content-header'],\n        styles[`fil-content-${css}`],\n        className,\n        {\n          [styles['fil-content-header-sortableasc']]:\n            onSort && order === null && defaultOrder === 'asc',\n          [styles['fil-content-header-sortabledesc']]:\n            onSort && order === null && defaultOrder === 'desc',\n          [styles['fil-content-header-sortasc']]: onSort && order === 'asc',\n          [styles['fil-content-header-sortdesc']]: onSort && order === 'desc'\n        }\n      )}\n    >\n      {t(`table.head_${label}`)}\n    </TableHeader>\n  )\n}\n\nexport default HeaderCell\n"
  },
  {
    "path": "src/modules/filelist/LoadMore.jsx",
    "content": "import cx from 'classnames'\nimport PropTypes from 'prop-types'\nimport React from 'react'\n\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\nimport { TableRow } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport { translate } from 'twake-i18n'\n\nimport styles from '@/styles/filelist.styl'\n\nconst LoadMore = ({ onClick, isLoading, text }) => (\n  <TableRow\n    className={cx(styles['fil-content-row'], styles['fil-content-row--center'])}\n  >\n    <Buttons\n      variant=\"secondary\"\n      onClick={onClick}\n      label={isLoading ? <Spinner noMargin /> : text}\n    />\n  </TableRow>\n)\n\nLoadMore.propTypes = {\n  onClick: PropTypes.func,\n  isLoading: PropTypes.bool,\n  text: PropTypes.string.isRequired\n}\n\nLoadMore.defaultProps = {\n  onClick: null,\n  isLoading: false\n}\nconst withTranslation =\n  BaseComponent =>\n  // eslint-disable-next-line\n    ({ t, ...props }) =>\n    <BaseComponent text={t('table.load_more')} {...props} />\n\nexport default translate()(withTranslation(LoadMore))\n"
  },
  {
    "path": "src/modules/filelist/LoadMoreV2.jsx",
    "content": "import cx from 'classnames'\nimport PropTypes from 'prop-types'\nimport React from 'react'\n\nimport LoadMore from 'cozy-ui/transpiled/react/LoadMore'\nimport { TableRow } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/filelist.styl'\n\nconst LoadMoreFiles = ({ fetchMore }) => {\n  const { t } = useI18n()\n  return (\n    <TableRow\n      className={cx(\n        styles['fil-content-row'],\n        styles['fil-content-row--center']\n      )}\n    >\n      <LoadMore fetchMore={fetchMore} label={t('table.load_more')} />\n    </TableRow>\n  )\n}\n\nLoadMoreFiles.propTypes = {\n  fetchMore: PropTypes.func.isRequired\n}\n\nexport default LoadMoreFiles\n"
  },
  {
    "path": "src/modules/filelist/MobileSortMenu.jsx",
    "content": "import React from 'react'\n\nimport ActionMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport ActionMenuWrapper from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuWrapper'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport Radio from 'cozy-ui/transpiled/react/Radios'\nimport { useI18n } from 'twake-i18n'\n\nimport { SORTABLE_ATTRIBUTES } from '@/config/sort'\n\nconst MobileSortMenu = ({ sort, onSort, onClose }) => {\n  const { t } = useI18n()\n  return (\n    <ActionMenuWrapper open onClose={onClose}>\n      {SORTABLE_ATTRIBUTES.map(({ attr }) => [\n        { attr, order: 'asc' },\n        { attr, order: 'desc' }\n      ])\n        .reduce((acc, val) => [...acc, ...val], [])\n        .map(({ attr, order }) => {\n          const labelId = `sort_by_${attr}_${order}`\n          return (\n            <ActionMenuItem\n              key={`key_${attr}_${order}`}\n              onClick={() => {\n                onSort(attr, order)\n                onClose()\n              }}\n            >\n              <ListItemIcon>\n                <Radio\n                  checked={sort.order === order && sort.attribute === attr}\n                  tabIndex={-1}\n                  disableRipple\n                  inputProps={{ 'aria-labelledby': labelId }}\n                />\n              </ListItemIcon>\n              <ListItemText\n                id={labelId}\n                primary={t(`table.mobile.head_${attr}_${order}`)}\n              />\n            </ActionMenuItem>\n          )\n        })}\n    </ActionMenuWrapper>\n  )\n}\n\nexport default MobileSortMenu\n"
  },
  {
    "path": "src/modules/filelist/cells/CarbonCopy.jsx",
    "content": "import cx from 'classnames'\nimport get from 'lodash/get'\nimport React from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport CheckIcon from 'cozy-ui/transpiled/react/Icons/Check'\nimport { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport AppIcon from 'cozy-ui-plus/dist/AppIcon'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/filelist.styl'\n\nimport CertificationTooltip from '@/modules/certifications/CertificationTooltip'\n\nconst CarbonCopyIcon = ({ file }) => {\n  const hasElectronicSafe = get(file, 'metadata.electronicSafe')\n  const konnectorName = get(file, 'cozyMetadata.uploadedBy.slug')\n\n  if (hasElectronicSafe) {\n    return <Icon icon={CheckIcon} />\n  }\n  return <AppIcon app={konnectorName} type=\"konnector\" />\n}\n\nconst CarbonCopy = ({ file }) => {\n  const { t } = useI18n()\n  const hasDataToshow = get(file, 'metadata.carbonCopy')\n\n  return (\n    <TableCell\n      className={cx(styles['fil-content-cell'], styles['fil-content-narrow'])}\n    >\n      {hasDataToshow ? (\n        <CertificationTooltip\n          body={t('table.tooltip.carbonCopy.title')}\n          caption={t('table.tooltip.carbonCopy.caption')}\n          content={<CarbonCopyIcon file={file} />}\n        />\n      ) : (\n        '—'\n      )}\n    </TableCell>\n  )\n}\n\nexport default CarbonCopy\n"
  },
  {
    "path": "src/modules/filelist/cells/CertificationsIcons.jsx",
    "content": "import get from 'lodash/get'\nimport React from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport CarbonCopyIcon from 'cozy-ui/transpiled/react/Icons/CarbonCopy'\nimport AppIcon from 'cozy-ui-plus/dist/AppIcon'\n\nimport styles from '@/styles/filelist.styl'\n\nconst CertificationsIcons = ({ attributes }) => {\n  const isCarbonCopy = get(attributes, 'metadata.carbonCopy')\n  const isElectronicSafe = get(attributes, 'metadata.electronicSafe')\n  const slug = get(attributes, 'cozyMetadata.uploadedBy.slug')\n\n  return (\n    <div className={styles['fil-file-certifications']}>\n      {(isCarbonCopy || isElectronicSafe) && (\n        <span className={styles['fil-file-certifications--separator']}>\n          {' - '}\n        </span>\n      )}\n      {isCarbonCopy &&\n        (isElectronicSafe ? (\n          <span data-testid=\"certificationsIcons-carbonCopyIcon\">\n            <Icon\n              icon={CarbonCopyIcon}\n              className={`u-mr-half ${styles['fil-file-certifications--icon']}`}\n            />\n          </span>\n        ) : (\n          <span data-testid=\"certificationsIcons-carbonCopyAppIcon\">\n            <AppIcon\n              app={slug}\n              className={styles['fil-file-certifications--icon']}\n              type=\"konnector\"\n              priority=\"registry\"\n            />\n          </span>\n        ))}\n      {isElectronicSafe && (\n        <span data-testid=\"certificationsIcons-electronicSafeAppIcon\">\n          <AppIcon\n            app={slug}\n            className={styles['fil-file-certifications--icon']}\n            type=\"konnector\"\n            priority=\"registry\"\n          />\n        </span>\n      )}\n    </div>\n  )\n}\n\nexport default CertificationsIcons\n"
  },
  {
    "path": "src/modules/filelist/cells/CertificationsIcons.spec.js",
    "content": "import { render } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\n\nimport CertificationsIcons from './CertificationsIcons'\nimport AppLike from 'test/components/AppLike'\n\nconst client = new createMockClient({})\nconst setup = ({ attributes }) => {\n  const root = render(\n    <AppLike client={client}>\n      <CertificationsIcons attributes={attributes} />\n    </AppLike>\n  )\n\n  return { root }\n}\n\ndescribe('CertificationsIcons', () => {\n  it('should render only carbon copy app icon', () => {\n    const { root } = setup({\n      attributes: {\n        metadata: { carbonCopy: true, electronicSafe: false },\n        cozyMetadata: { uploadedBy: { slug: 'pajemploi' } }\n      }\n    })\n    const { queryByTestId } = root\n\n    expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeTruthy()\n    expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeFalsy()\n    expect(\n      queryByTestId('certificationsIcons-electronicSafeAppIcon')\n    ).toBeFalsy()\n  })\n\n  it('should render only electronic safe app icon', () => {\n    const { root } = setup({\n      attributes: {\n        metadata: { carbonCopy: false, electronicSafe: true },\n        cozyMetadata: { uploadedBy: { slug: 'pajemploi' } }\n      }\n    })\n    const { queryByTestId } = root\n\n    expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeFalsy()\n    expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeFalsy()\n    expect(\n      queryByTestId('certificationsIcons-electronicSafeAppIcon')\n    ).toBeTruthy()\n  })\n\n  it('should render carbon copy icon and electronic safe app icon', () => {\n    const { root } = setup({\n      attributes: {\n        metadata: { carbonCopy: true, electronicSafe: true },\n        cozyMetadata: { uploadedBy: { slug: 'pajemploi' } }\n      }\n    })\n    const { queryByTestId } = root\n\n    expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeFalsy()\n    expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeTruthy()\n    expect(\n      queryByTestId('certificationsIcons-electronicSafeAppIcon')\n    ).toBeTruthy()\n  })\n\n  it('should render no certifications icon', () => {\n    const { root } = setup({\n      attributes: {\n        metadata: { carbonCopy: false, electronicSafe: false },\n        cozyMetadata: { uploadedBy: { slug: 'pajemploi' } }\n      }\n    })\n    const { queryByTestId } = root\n\n    expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeFalsy()\n    expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeFalsy()\n    expect(\n      queryByTestId('certificationsIcons-electronicSafeAppIcon')\n    ).toBeFalsy()\n  })\n\n  it('should render no certifications icon and not throw error with empty attributes', () => {\n    const { root } = setup({\n      attributes: {}\n    })\n    const { queryByTestId } = root\n\n    expect(queryByTestId('certificationsIcons-carbonCopyAppIcon')).toBeFalsy()\n    expect(queryByTestId('certificationsIcons-carbonCopyIcon')).toBeFalsy()\n    expect(\n      queryByTestId('certificationsIcons-electronicSafeAppIcon')\n    ).toBeFalsy()\n  })\n})\n"
  },
  {
    "path": "src/modules/filelist/cells/ElectronicSafe.jsx",
    "content": "import cx from 'classnames'\nimport get from 'lodash/get'\nimport React from 'react'\n\nimport { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport AppIcon from 'cozy-ui-plus/dist/AppIcon'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/filelist.styl'\n\nimport CertificationTooltip from '@/modules/certifications/CertificationTooltip'\n\nconst ElectronicSafe = ({ file }) => {\n  const { t } = useI18n()\n\n  const hasDataToshow = get(file, 'metadata.electronicSafe')\n  const konnectorName = get(file, 'cozyMetadata.uploadedBy.slug')\n\n  return (\n    <TableCell\n      className={cx(styles['fil-content-cell'], styles['fil-content-narrow'])}\n    >\n      {hasDataToshow ? (\n        <CertificationTooltip\n          body={t('table.tooltip.electronicSafe.title')}\n          caption={t('table.tooltip.electronicSafe.caption')}\n          content={<AppIcon app={konnectorName} type=\"konnector\" />}\n        />\n      ) : (\n        '—'\n      )}\n    </TableCell>\n  )\n}\n\nexport default ElectronicSafe\n"
  },
  {
    "path": "src/modules/filelist/cells/Empty.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\n\nimport { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\n\nimport styles from '@/styles/filelist.styl'\n\nconst Empty = ({ className }) => {\n  return (\n    <TableCell className={cx(styles['fil-content-cell'], className)}>\n      —\n    </TableCell>\n  )\n}\n\nexport default Empty\n"
  },
  {
    "path": "src/modules/filelist/cells/FileAction.jsx",
    "content": "import cx from 'classnames'\nimport React, { forwardRef } from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport DotsIcon from 'cozy-ui/transpiled/react/Icons/Dots'\nimport { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\n\nimport styles from '@/styles/filelist.styl'\n\nconst FileAction = forwardRef(function FileAction(\n  { t, onClick, disabled, isInSyncFromSharing },\n  ref\n) {\n  return (\n    <TableCell\n      className={cx(\n        styles['fil-content-cell'],\n        styles['fil-content-file-action'],\n        { [styles['fil-content-file-action--disabled']]: isInSyncFromSharing }\n      )}\n    >\n      <span ref={ref}>\n        <IconButton\n          disabled={disabled}\n          onClick={onClick}\n          size=\"small\"\n          aria-label={t('Toolbar.more')}\n        >\n          <Icon icon={DotsIcon} size={17} />\n        </IconButton>\n      </span>\n    </TableCell>\n  )\n})\n\nexport default FileAction\n"
  },
  {
    "path": "src/modules/filelist/cells/FileName.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\nimport { Link } from 'react-router-dom'\n\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport MidEllipsis from 'cozy-ui/transpiled/react/MidEllipsis'\nimport { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/filelist.styl'\n\nimport { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'\nimport RenameInput from '@/modules/drive/RenameInput'\nimport CertificationsIcons from '@/modules/filelist/cells/CertificationsIcons'\nimport {\n  getFileNameAndExtension,\n  makeParentFolderPath\n} from '@/modules/filelist/helpers'\n\nconst FileName = ({\n  attributes,\n  isRenaming,\n  interactive,\n  withFilePath,\n  isMobile,\n  formattedSize,\n  formattedUpdatedAt,\n  refreshFolderContent,\n  isInSyncFromSharing\n}) => {\n  const { t } = useI18n()\n  const { viewType } = useViewSwitcherContext()\n  const classes = cx(\n    styles['fil-content-cell'],\n    styles['fil-content-file'],\n    { [styles['fil-content-file-openable']]: !isRenaming && interactive },\n    { [styles['fil-content-row-disabled']]: isInSyncFromSharing },\n    { [styles['fil-content-grid-view']]: viewType === 'grid' }\n  )\n\n  const { title, filename, extension } = getFileNameAndExtension(attributes, t)\n  const parentFolderPath = makeParentFolderPath(attributes)\n\n  return (\n    <TableCell className={classes}>\n      {isRenaming ? (\n        <RenameInput\n          file={attributes}\n          refreshFolderContent={refreshFolderContent}\n        />\n      ) : (\n        <div className={styles['fil-file']}>\n          <div className={styles['fil-file-filename']}>\n            <div className={styles['fil-file-filename-wrapper']}>\n              <div\n                data-testid=\"fil-file-filename-and-ext\"\n                className={styles['fil-file-filename-and-ext']}\n                title={title}\n              >\n                {filename}\n                {extension && (\n                  <span className={styles['fil-content-ext']}>{extension}</span>\n                )}\n              </div>\n            </div>\n          </div>\n          {withFilePath &&\n            parentFolderPath &&\n            (isMobile ? (\n              <div\n                className={styles['fil-file-description']}\n                title={filename + extension}\n              >\n                <MidEllipsis\n                  className={styles['fil-file-description--path']}\n                  text={parentFolderPath}\n                />\n                <CertificationsIcons attributes={attributes} />\n              </div>\n            ) : (\n              <Link\n                to={`/folder/${attributes.dir_id}`}\n                // Please do not modify the className as it is used in event handling, see FileOpener#46\n                className={styles['fil-file-path']}\n              >\n                <MidEllipsis text={parentFolderPath} />\n              </Link>\n            ))}\n          {!withFilePath &&\n            (isDirectory(attributes) || (\n              <div className={styles['fil-file-infos']}>\n                {`${formattedUpdatedAt}${\n                  formattedSize ? ` - ${formattedSize}` : ''\n                }`}\n                {isMobile && <CertificationsIcons attributes={attributes} />}\n              </div>\n            ))}\n        </div>\n      )}\n    </TableCell>\n  )\n}\n\nexport default FileName\n"
  },
  {
    "path": "src/modules/filelist/cells/LastUpdate.jsx",
    "content": "import cx from 'classnames'\nimport PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/filelist.styl'\n\nconst LastUpdate = ({ date, formatted = '—' }) => {\n  const { f, t } = useI18n()\n\n  return (\n    <TableCell\n      className={cx(styles['fil-content-cell'], styles['fil-content-date'])}\n      {...(formatted !== '—' && {\n        title: f(date, t('LastUpdate.titleFormat'))\n      })}\n    >\n      <time dateTime={date}>{formatted}</time>\n    </TableCell>\n  )\n}\n\nLastUpdate.propTypes = {\n  date: PropTypes.string,\n  formatted: PropTypes.string\n}\n\nexport default React.memo(LastUpdate)\n"
  },
  {
    "path": "src/modules/filelist/cells/SelectBox.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\n\nimport Checkbox from 'cozy-ui/transpiled/react/Checkbox'\nimport { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\n\nimport styles from '@/styles/filelist.styl'\n\nconst SelectBox = ({\n  withSelectionCheckbox,\n  selected,\n  onClick,\n  disabled,\n  viewType\n}) => {\n  return (\n    <TableCell\n      className={cx(\n        styles['fil-content-cell'],\n        styles['fil-content-file-select']\n      )}\n      {...(!disabled && { onClick })}\n    >\n      {withSelectionCheckbox && !disabled && (\n        <Checkbox\n          checked={selected}\n          size={viewType === 'grid' ? 'small' : 'medium'}\n          onChange={() => {\n            // handled by onClick on the <TableCell>\n          }}\n        />\n      )}\n    </TableCell>\n  )\n}\n\nexport default SelectBox\n"
  },
  {
    "path": "src/modules/filelist/cells/ShareContent.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\nimport { useNavigate, useLocation } from 'react-router-dom'\n\nimport { SharedStatus, useSharingContext } from 'cozy-sharing'\n\nimport styles from '@/styles/filelist.styl'\n\nimport { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'\nimport { joinPath } from '@/lib/path'\n\nconst ShareContent = ({ file, disabled, isInSyncFromSharing }) => {\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n  const { byDocId } = useSharingContext()\n  const { viewType } = useViewSwitcherContext()\n\n  const handleClick = e => {\n    // Avoid to trigger row click from FileOpener\n    e.preventDefault()\n    e.stopPropagation()\n\n    if (!disabled) {\n      // should be only disabled\n      navigate(joinPath(pathname, `file/${file._id}/share`))\n    }\n  }\n\n  const isShared = byDocId[file.id] !== undefined\n\n  return (\n    <div\n      className={cx(styles['fil-content-sharestatus'], {\n        [styles['fil-content-sharestatus--disabled']]: disabled\n      })}\n    >\n      {isInSyncFromSharing || !isShared ? (\n        viewType === 'list' ? (\n          <span data-testid=\"fil-content-sharestatus--noAvatar\">—</span>\n        ) : null\n      ) : (\n        <SharedStatus onClick={handleClick} docId={file.id} />\n      )}\n    </div>\n  )\n}\n\nexport { ShareContent }\n"
  },
  {
    "path": "src/modules/filelist/cells/SharingShortcutBadge.jsx",
    "content": "import cx from 'classnames'\nimport PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { isSharingShortcutNew } from 'cozy-client/dist/models/file'\nimport Avatar from 'cozy-ui/transpiled/react/Avatar'\nimport { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/filelist.styl'\n\nconst SharingShortcutBadge = ({ file }) => {\n  const { t } = useI18n()\n\n  return (\n    <TableCell\n      className={cx(\n        styles['fil-content-cell'],\n        styles['fil-content-sharing-shortcut']\n      )}\n    >\n      {isSharingShortcutNew(file) ? (\n        <Avatar color=\"var(--errorColor)\" textColor=\"var(--white)\" size=\"xs\">\n          <span\n            style={{ fontSize: '11px', lineHeight: '1rem' }}\n            aria-label={t('table.row_sharing_shortcut_aria_label')}\n          >\n            1\n          </span>\n        </Avatar>\n      ) : null}\n    </TableCell>\n  )\n}\n\nSharingShortcutBadge.propTypes = {\n  file: PropTypes.object,\n  isInSyncFromSharing: PropTypes.bool\n}\n\nexport { SharingShortcutBadge }\n"
  },
  {
    "path": "src/modules/filelist/cells/Size.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\n\nimport { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\n\nimport styles from '@/styles/filelist.styl'\n\nconst _Size = ({ filesize = '—' }) => (\n  <TableCell\n    className={cx(styles['fil-content-cell'], styles['fil-content-size'])}\n  >\n    {filesize}\n  </TableCell>\n)\n\nconst Size = React.memo(_Size)\n\nexport default Size\n"
  },
  {
    "path": "src/modules/filelist/cells/Status.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\n\nimport { TableCell } from 'cozy-ui/transpiled/react/deprecated/Table'\n\nimport styles from '@/styles/filelist.styl'\n\nimport { ShareContent } from '@/modules/filelist/cells/ShareContent'\n\nconst Status = ({ file, disabled, isInSyncFromSharing }) => {\n  return (\n    <TableCell\n      className={cx(styles['fil-content-cell'], styles['fil-content-status'])}\n    >\n      <ShareContent\n        file={file}\n        disabled={disabled}\n        isInSyncFromSharing={isInSyncFromSharing}\n      />\n    </TableCell>\n  )\n}\n\nexport default Status\n"
  },
  {
    "path": "src/modules/filelist/cells/index.jsx",
    "content": "export { default as SelectBox } from './SelectBox'\nexport { default as FileName } from './FileName'\nexport { default as LastUpdate } from './LastUpdate'\nexport { default as Size } from './Size'\nexport { default as Status } from './Status'\nexport { default as FileAction } from './FileAction'\nexport { default as CarbonCopy } from './CarbonCopy'\nexport { default as ElectronicSafe } from './ElectronicSafe'\nexport { default as Empty } from './Empty'\nexport { SharingShortcutBadge } from './SharingShortcutBadge'\n"
  },
  {
    "path": "src/modules/filelist/duck.js",
    "content": "const SHOW_NEW_FOLDER_INPUT = 'SHOW_NEW_FOLDER_INPUT'\nconst HIDE_NEW_FOLDER_INPUT = 'HIDE_NEW_FOLDER_INPUT'\n\nexport const showNewFolderInput = () => ({\n  type: SHOW_NEW_FOLDER_INPUT\n})\n\nexport const hideNewFolderInput = () => ({\n  type: HIDE_NEW_FOLDER_INPUT\n})\n\nconst initialState = {\n  isTypingNewFolderName: false\n}\n\nconst filelist = (state = initialState, action) => {\n  switch (action.type) {\n    case SHOW_NEW_FOLDER_INPUT:\n      return { ...state, isTypingNewFolderName: true }\n    case HIDE_NEW_FOLDER_INPUT:\n      return { ...state, isTypingNewFolderName: false }\n    default:\n      return state\n  }\n}\n\nexport default filelist\n\nexport const isTypingNewFolderName = state =>\n  state.filelist.isTypingNewFolderName\n"
  },
  {
    "path": "src/modules/filelist/fileopener.styl",
    "content": "@supports (display: contents)\n    .file-opener\n        display contents\n\n@supports not (display: contents) // @stylint ignore\n    .file-opener\n        display flex\n        flex 1 1 auto\n        align-items center\n        width 100%\n\n.file-opener__a\n    text-decoration none\n    color var(--secondaryTextColor)\n"
  },
  {
    "path": "src/modules/filelist/getCaretPositionFromPoint.js",
    "content": "/**\n * Get the caret offset in a text input from mouse coordinates.\n *\n * Uses `document.caretPositionFromPoint` (standard, Firefox) or\n * `document.caretRangeFromPoint` (legacy, Chrome/Safari) to determine\n * which character position the user clicked on.\n *\n * @param {number} x - clientX from the mouse event\n * @param {number} y - clientY from the mouse event\n * @returns {number|null} The character offset, or null if it cannot be determined\n */\nexport const getCaretPositionFromPoint = (x, y) => {\n  if (document.caretPositionFromPoint) {\n    const pos = document.caretPositionFromPoint(x, y)\n    if (pos) return pos.offset\n  } else if (document.caretRangeFromPoint) {\n    const range = document.caretRangeFromPoint(x, y)\n    if (range) return range.startOffset\n  }\n  return null\n}\n"
  },
  {
    "path": "src/modules/filelist/headers/CarbonCopy.jsx",
    "content": "import React from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport CarbonCopyIcon from 'cozy-ui/transpiled/react/Icons/CarbonCopy'\nimport { TableHeader } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/filelist.styl'\n\nimport CertificationTooltip from '@/modules/certifications/CertificationTooltip'\n\nconst CarbonCopyHeader = () => {\n  const { t } = useI18n()\n\n  return (\n    <TableHeader className={styles['fil-content-header']}>\n      <CertificationTooltip\n        body={t('table.tooltip.carbonCopy.title')}\n        caption={t('table.tooltip.carbonCopy.caption')}\n        content={<Icon icon={CarbonCopyIcon} size={16} />}\n      />\n    </TableHeader>\n  )\n}\n\nexport default CarbonCopyHeader\n"
  },
  {
    "path": "src/modules/filelist/headers/ElectronicSafe.jsx",
    "content": "import React from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport SafeIcon from 'cozy-ui/transpiled/react/Icons/Safe'\nimport { TableHeader } from 'cozy-ui/transpiled/react/deprecated/Table'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/filelist.styl'\n\nimport CertificationTooltip from '@/modules/certifications/CertificationTooltip'\n\nconst ElectronicSafeHeader = () => {\n  const { t } = useI18n()\n\n  return (\n    <TableHeader className={styles['fil-content-header']}>\n      <CertificationTooltip\n        body={t('table.tooltip.electronicSafe.title')}\n        caption={t('table.tooltip.electronicSafe.caption')}\n        content={<Icon icon={SafeIcon} size={16} />}\n      />\n    </TableHeader>\n  )\n}\n\nexport default ElectronicSafeHeader\n"
  },
  {
    "path": "src/modules/filelist/headers/index.jsx",
    "content": "export { default as CarbonCopy } from './CarbonCopy'\nexport { default as ElectronicSafe } from './ElectronicSafe'\n"
  },
  {
    "path": "src/modules/filelist/helpers.ts",
    "content": "import { splitFilename } from 'cozy-client/dist/models/file'\nimport type { IOCozyFile } from 'cozy-client/types/types'\n\nimport type { File } from '@/components/FolderPicker/types'\nimport {\n  TRASH_DIR_ID,\n  ROOT_DIR_ID,\n  SHARED_DRIVES_DIR_ID,\n  SHARINGS_VIEW_ROUTE\n} from '@/constants/config'\nimport { isNextcloudShortcut } from '@/modules/nextcloud/helpers'\n\nexport const isDriveBackedFile = (file: File): boolean => !!file.driveId\n\nexport const makeParentFolderPath = (file: File): string => {\n  if (file.dir_id === SHARED_DRIVES_DIR_ID) {\n    return SHARINGS_VIEW_ROUTE\n  }\n\n  if (!file.path) return ''\n\n  return file.dir_id === ROOT_DIR_ID\n    ? file.path.replace(file.name, '')\n    : file.path.replace(`/${file.name}`, '')\n}\n\nexport const getFileNameAndExtension = (\n  file: File,\n  t: (key: string) => string\n): {\n  title: string\n  filename: string\n  extension?: string\n} => {\n  if (file._id === TRASH_DIR_ID) {\n    return {\n      title: t('FileName.trash'),\n      filename: t('FileName.trash')\n    }\n  }\n\n  // we can have ROOT_DIR_ID in some case, like in sharing view when fetching docs for the first time\n  // in that case we want to do the same trick as for SHARED_DRIVES_DIR_ID\n  if (file._id === SHARED_DRIVES_DIR_ID || file._id === ROOT_DIR_ID) {\n    return {\n      title: t('FileName.sharedDrive'),\n      filename: t('FileName.sharedDrive')\n    }\n  }\n\n  const { filename, extension } = splitFilename(file)\n\n  if (file._type === 'io.cozy.files' && isNextcloudShortcut(file)) {\n    return {\n      title: filename,\n      filename: filename\n    }\n  }\n\n  return {\n    title: file.name,\n    filename,\n    extension\n  }\n}\n\nexport interface FileWithAntivirusScan {\n  antivirus_scan?: {\n    status?: 'clean' | 'infected' | 'skipped' | 'error' | 'pending'\n  }\n}\n\nexport const isInfected = (\n  file?: (FileWithAntivirusScan & Partial<IOCozyFile>) | null\n): boolean => {\n  return file?.antivirus_scan?.status === 'infected'\n}\n\nexport const isNotScanned = (\n  file?: (FileWithAntivirusScan & Partial<IOCozyFile>) | null\n): boolean => {\n  const status = file?.antivirus_scan?.status\n  return status === 'pending' || status === 'skipped' || status === 'error'\n}\n"
  },
  {
    "path": "src/modules/filelist/icons/BadgeKonnector.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { isQueryLoading, isReferencedBy, useQuery } from 'cozy-client'\nimport Badge from 'cozy-ui/transpiled/react/Badge'\nimport { makeStyles } from 'cozy-ui/transpiled/react/styles'\nimport AppIcon from 'cozy-ui-plus/dist/AppIcon'\n\nimport { DOCTYPE_KONNECTORS } from '@/lib/doctypes'\nimport { getKonnectorSlugFromFile } from '@/lib/konnectors'\nimport { buildFileOrFolderByIdQuery } from '@/queries'\n\nconst useStyle = makeStyles({\n  badge: {\n    backgroundColor: 'var(--white)',\n    height: '1.5rem',\n    minWidth: '1.5rem',\n    borderRadius: '0.375rem',\n    border: '1px solid var(--borderMainColor)'\n  },\n  appIcon: {\n    width: '75%',\n    height: '75%'\n  },\n  anchorOriginBottomRightCircular: {\n    bottom: '10px'\n  }\n})\n\nexport const BadgeKonnector = ({ file, children }) => {\n  const { badge, anchorOriginBottomRightCircular, appIcon } = useStyle()\n  const konnectorSlug = getKonnectorSlugFromFile(file)\n\n  // Check if the parent folder is a konnector folder, because if have no file in your account folder, its considered as a konnector folder\n  const parentFolderQuery = buildFileOrFolderByIdQuery(file.dir_id)\n  const { data: parentFolder, ...parentFolderQueryLeft } = useQuery(\n    parentFolderQuery.definition,\n    parentFolderQuery.options\n  )\n  const isParentQueryLoading = isQueryLoading(parentFolderQueryLeft)\n  const hasKonnectorParentFolder =\n    isReferencedBy(parentFolder, DOCTYPE_KONNECTORS) ||\n    // To guarantee the exclusion of account folders\n    (isReferencedBy(file, DOCTYPE_KONNECTORS) &&\n      isReferencedBy(file, 'io.cozy.accounts.sourceAccountIdentifier'))\n\n  const withoutKonnectorBadge =\n    isParentQueryLoading ||\n    hasKonnectorParentFolder ||\n    !isReferencedBy(file, DOCTYPE_KONNECTORS)\n\n  if (withoutKonnectorBadge) {\n    return <>{children}</>\n  }\n\n  return (\n    <Badge\n      classes={{ badge, anchorOriginBottomRightCircular }}\n      anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}\n      badgeContent={\n        <AppIcon\n          className={appIcon}\n          type=\"konnector\"\n          app={{ slug: konnectorSlug }}\n        />\n      }\n    >\n      {children}\n    </Badge>\n  )\n}\n\nBadgeKonnector.propTypes = {\n  file: PropTypes.object.isRequired\n}\n"
  },
  {
    "path": "src/modules/filelist/icons/FileIcon.jsx",
    "content": "import React from 'react'\n\nimport FileImageLoader from 'cozy-ui-plus/dist/FileImageLoader'\n\nimport styles from '@/styles/filelist.styl'\n\nimport { isDriveBackedFile } from '@/modules/filelist/helpers'\nimport FileIconMime from '@/modules/filelist/icons/FileIconMime'\nimport FileIconShortcut from '@/modules/filelist/icons/FileIconShortcut'\n\nconst FileIcon = ({ file, size, viewType = 'list' }) => {\n  const isImage = file.class === 'image'\n  const isShortcut = file.class === 'shortcut' && !isDriveBackedFile(file)\n  if (isImage || file.class === 'pdf')\n    return (\n      <FileImageLoader\n        key={file._id}\n        file={file}\n        linkType={viewType === 'grid' ? 'small' : 'tiny'}\n        render={src => (\n          <img\n            src={src}\n            width={size || 32}\n            height={size || 32}\n            className={styles['fil-file-thumbnail-image']}\n          />\n        )}\n        renderFallback={() => <FileIconMime file={file} size={size} />}\n      />\n    )\n  else if (isShortcut) return <FileIconShortcut file={file} size={size} />\n  else return <FileIconMime file={file} size={size} />\n}\n\nexport default FileIcon\n"
  },
  {
    "path": "src/modules/filelist/icons/FileIcon.spec.jsx",
    "content": "import { render } from '@testing-library/react'\nimport React from 'react'\n\nimport FileIcon from './FileIcon'\n\njest.mock('cozy-flags', () => () => true)\n\njest.mock('cozy-ui-plus/dist/FileImageLoader', () => () => (\n  <div data-testid=\"FileImageLoader\" />\n))\n\ndescribe('FileIcon', () => {\n  it('should return file image loader when file is image', () => {\n    // Given\n    const file = { class: 'image' }\n\n    // When\n    const { getByTestId } = render(<FileIcon file={file} />)\n\n    // Then\n    expect(getByTestId('FileImageLoader')).toBeInTheDocument()\n  })\n\n  it('should return file image loader when file is pdf', () => {\n    // Given\n    const file = { class: 'pdf' }\n\n    // When\n    const { getByTestId } = render(<FileIcon file={file} />)\n\n    // Then\n    expect(getByTestId('FileImageLoader')).toBeInTheDocument()\n  })\n})\n"
  },
  {
    "path": "src/modules/filelist/icons/FileIconMime.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\n\nimport getMimeTypeIcon from '@/lib/getMimeTypeIcon'\nimport { CustomizedIcon } from '@/modules/views/Folder/CustomizedIcon'\n\nconst FileIconMime = ({ file, size = 32 }) => {\n  const isDir = isDirectory(file)\n\n  if (\n    isDir &&\n    (file.metadata?.decorations?.color || file.metadata?.decorations?.icon)\n  ) {\n    return (\n      <CustomizedIcon\n        selectedColor={file.metadata.decorations.color}\n        selectedIcon={file.metadata.decorations.icon}\n        selectedIconColor={file.metadata.decorations.icon_color}\n        size={size}\n      />\n    )\n  } else {\n    return (\n      <Icon icon={getMimeTypeIcon(isDir, file.name, file.mime)} size={size} />\n    )\n  }\n}\n\nFileIconMime.propTypes = {\n  file: PropTypes.shape({\n    type: PropTypes.string,\n    mime: PropTypes.string,\n    name: PropTypes.string\n  }).isRequired,\n  size: PropTypes.number\n}\n\nexport default FileIconMime\n"
  },
  {
    "path": "src/modules/filelist/icons/FileIconShortcut.jsx",
    "content": "import React, { useState } from 'react'\n\nimport { useClient, useFetchShortcut } from 'cozy-client'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport GlobeIcon from 'cozy-ui/transpiled/react/Icons/Globe'\n\nconst FileIconShortcut = ({ file, size = 32 }) => {\n  const client = useClient()\n  const { shortcutImg } = useFetchShortcut(client, file.id)\n  const [isBroken, setBroken] = useState(null)\n\n  return (\n    <>\n      <div style={{ display: shortcutImg && !isBroken ? 'block' : 'none' }}>\n        <img\n          src={shortcutImg}\n          width={size}\n          height={size}\n          onError={() => {\n            setBroken(true)\n          }}\n        />\n      </div>\n      <div\n        style={{\n          display: !shortcutImg || isBroken ? 'block' : 'none'\n        }}\n      >\n        <Icon icon={GlobeIcon} size={size} color=\"var(--iconTextColor)\" />\n      </div>\n    </>\n  )\n}\n\nexport default FileIconShortcut\n"
  },
  {
    "path": "src/modules/filelist/icons/FileThumbnail.tsx",
    "content": "import React from 'react'\n\nimport { isReferencedBy, models } from 'cozy-client'\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport { SharedBadge, SharingOwnerAvatar } from 'cozy-sharing'\nimport Badge from 'cozy-ui/transpiled/react/Badge'\nimport Box from 'cozy-ui/transpiled/react/Box'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport FileTypeServerIcon from 'cozy-ui/transpiled/react/Icons/FileTypeServer'\nimport LinkIcon from 'cozy-ui/transpiled/react/Icons/Link'\nimport TrashDuotoneIcon from 'cozy-ui/transpiled/react/Icons/TrashDuotone'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\n\nimport styles from '@/styles/filelist.styl'\n\nimport type { File, FolderPickerEntry } from '@/components/FolderPicker/types'\nimport { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'\nimport { DOCTYPE_KONNECTORS } from '@/lib/doctypes'\nimport { isInfected, isDriveBackedFile } from '@/modules/filelist/helpers'\nimport { BadgeKonnector } from '@/modules/filelist/icons/BadgeKonnector'\nimport FileIcon from '@/modules/filelist/icons/FileIcon'\nimport FileIconMime from '@/modules/filelist/icons/FileIconMime'\nimport { SharingShortcutIcon } from '@/modules/filelist/icons/SharingShortcutIcon'\nimport {\n  isNextcloudShortcut,\n  isNextcloudFile\n} from '@/modules/nextcloud/helpers'\n\ninterface FileThumbnailProps {\n  file: File | FolderPickerEntry\n  size?: number\n  isInSyncFromSharing?: boolean\n  showSharedBadge?: boolean\n  componentsProps?: {\n    sharedBadge?: object\n  }\n}\n\nconst FileThumbnail: React.FC<FileThumbnailProps> = ({\n  file,\n  size,\n  isInSyncFromSharing,\n  showSharedBadge = false,\n  componentsProps = {\n    sharedBadge: {}\n  }\n}) => {\n  const { viewType } = useViewSwitcherContext()\n\n  const fileIcon = <FileIcon file={file} size={size} viewType={viewType} />\n\n  if (isNextcloudFile(file)) {\n    return <FileIconMime file={file} size={size} />\n  }\n\n  if (file._id?.endsWith('.trash-dir')) {\n    return size && size >= 48 ? (\n      <Box\n        className=\"u-flex u-flex-items-center u-flex-justify-center u-bdrs-4\"\n        width={size}\n        height={size}\n        bgcolor=\"var(--contrastBackgroundColor)\"\n      >\n        <Icon icon={TrashDuotoneIcon} size={48} />\n      </Box>\n    ) : (\n      <Icon icon={TrashDuotoneIcon} size={size ?? 32} />\n    )\n  }\n\n  if (isNextcloudShortcut(file)) {\n    return (\n      <Icon className=\"u-mr-half\" icon={FileTypeServerIcon} size={size ?? 32} />\n    )\n  }\n\n  const isSharingShortcut =\n    models.file.isSharingShortcut(file) &&\n    !isInSyncFromSharing &&\n    !isDriveBackedFile(file)\n  const isRegularShortcut =\n    !isSharingShortcut &&\n    file.class === 'shortcut' &&\n    !isInSyncFromSharing &&\n    !isDriveBackedFile(file)\n  const isSimpleFile =\n    !isSharingShortcut && !isRegularShortcut && !isInSyncFromSharing\n  const isFolder = isSimpleFile && isDirectory(file)\n  const isKonnectorFolder = isReferencedBy(file, DOCTYPE_KONNECTORS)\n\n  if (isFolder) {\n    if (size && size >= 48) {\n      return (\n        <Box\n          className=\"u-flex u-flex-items-center u-flex-justify-center u-bdrs-4\"\n          width={size}\n          height={size}\n          bgcolor={viewType === 'list' ? 'var(--contrastBackgroundColor)' : ''}\n        >\n          {isKonnectorFolder ? (\n            <BadgeKonnector file={file}>\n              {fileIcon}\n              {file.class !== 'shortcut' &&\n                showSharedBadge &&\n                viewType === 'grid' && (\n                  <SharedBadge\n                    docId={file._id}\n                    {...componentsProps.sharedBadge}\n                    small\n                  />\n                )}\n            </BadgeKonnector>\n          ) : (\n            <>\n              {fileIcon}\n              {file.class !== 'shortcut' &&\n                showSharedBadge &&\n                viewType === 'grid' && (\n                  <SharedBadge\n                    docId={file._id}\n                    {...componentsProps.sharedBadge}\n                    small\n                  />\n                )}\n            </>\n          )}\n        </Box>\n      )\n    }\n  }\n  if (isKonnectorFolder) {\n    return <BadgeKonnector file={file}>{fileIcon}</BadgeKonnector>\n  }\n\n  const infected = isInfected(file)\n\n  const fileIconWithInfection = infected ? (\n    <Badge\n      size=\"large\"\n      badgeContent={\n        <Icon icon=\"warning-circle\" color=\"var(--errorColor)\" size={20} />\n      }\n      withBorder={false}\n      anchorOrigin={{\n        vertical: 'bottom',\n        horizontal: 'right'\n      }}\n    >\n      {fileIcon}\n    </Badge>\n  ) : (\n    fileIcon\n  )\n\n  return (\n    <>\n      {isSimpleFile && fileIconWithInfection}\n      {isRegularShortcut && (\n        <>\n          {viewType !== 'grid' ? (\n            <Badge\n              anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}\n              badgeContent={\n                <div\n                  className=\"u-h-1-half u-miw-1-half u-bdrs-circle u-flex u-flex-items-center u-flex-justify-center\"\n                  style={{\n                    backgroundColor: 'var(--paperBackgroundColor)',\n                    color: 'var(--iconTextColor)',\n                    boxShadow: 'var(--shadow3)'\n                  }}\n                >\n                  <Icon icon={LinkIcon} size=\"10\" />\n                </div>\n              }\n            >\n              {fileIcon}\n            </Badge>\n          ) : (\n            fileIcon\n          )}\n        </>\n      )}\n      {isSharingShortcut && (\n        <Badge\n          badgeContent={\n            <div\n              className=\"u-h-auto u-miw-auto\"\n              style={{\n                padding: '3px',\n                backgroundColor: 'white',\n                color: 'var(--coolGrey)',\n                border: '1px solid var(--silver)',\n                borderRadius: '6px'\n              }}\n            >\n              <SharingShortcutIcon file={file} size={16} />\n            </div>\n          }\n          anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}\n        >\n          <SharingOwnerAvatar docId={file._id} size=\"small\" />\n        </Badge>\n      )}\n      {isInSyncFromSharing && (\n        <span data-testid=\"fil-file-thumbnail--spinner\">\n          <Spinner\n            size=\"large\"\n            className={styles['fil-file-thumbnail--spinner']}\n          />\n        </span>\n      )}\n      {/**\n       * @todo\n       * Since for shortcut we already display a kind of badge we're currently just\n       * not displaying the sharedBadge. Besides on desktop we have added sharing avatars.\n       * The next functionnal's task is to work on sharing and we'll remove\n       * this badge from here. In the meantime, we take this workaround\n       */}\n      {file.class !== 'shortcut' &&\n        showSharedBadge &&\n        !isInSyncFromSharing &&\n        viewType === 'grid' && (\n          <SharedBadge\n            docId={file._id}\n            {...componentsProps.sharedBadge}\n            xsmall\n          />\n        )}\n    </>\n  )\n}\n\nexport default FileThumbnail\n"
  },
  {
    "path": "src/modules/filelist/icons/SharingShortcutIcon.jsx",
    "content": "import React from 'react'\n\nimport {\n  getSharingShortcutTargetMime,\n  getSharingShortcutTargetDoctype\n} from 'cozy-client/dist/models/file'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\n\nimport { DOCTYPE_FILES } from '@/lib/doctypes'\nimport getMimeTypeIcon from '@/lib/getMimeTypeIcon'\nimport FileIconShortcut from '@/modules/filelist/icons/FileIconShortcut'\n\nconst SharingShortcutIcon = ({ file, size }) => {\n  const targetMimeType = getSharingShortcutTargetMime(file)\n  const targetDoctype = getSharingShortcutTargetDoctype(file)\n  const isShortcut = targetMimeType === 'application/internet-shortcut'\n  const targetIsDirectory =\n    targetMimeType === '' && targetDoctype === DOCTYPE_FILES\n\n  return isShortcut ? (\n    <FileIconShortcut file={file} size={size} />\n  ) : (\n    <Icon\n      icon={getMimeTypeIcon(targetIsDirectory, file.name, targetMimeType)}\n      size={size}\n    />\n  )\n}\n\nexport { SharingShortcutIcon }\n"
  },
  {
    "path": "src/modules/filelist/useFormattedUpdatedAt.js",
    "content": "import { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\n/**\n * Returns the formatted \"last updated\" string for a file row, or undefined\n * when the date is falsy.\n *\n * The guard matters: twake-i18n's `f()` calls date-fns `format()`, which\n * throws on falsy/invalid dates. The library catches the throw but logs it\n * via `console.error('Error in initFormat', ...)`, which our Sentry config\n * captures. Synthetic rows in the file list (shared-drive entries, sharing\n * placeholders) often lack `updated_at`/`created_at`, so we'd otherwise\n * emit a Sentry event for every such row.\n *\n * @param {string | undefined} updatedAt\n * @returns {string | undefined}\n */\nexport const useFormattedUpdatedAt = updatedAt => {\n  const { f, t } = useI18n()\n  const { isExtraLarge } = useBreakpoints()\n\n  if (!updatedAt) return undefined\n\n  return f(\n    updatedAt,\n    isExtraLarge\n      ? t('table.row_update_format_full')\n      : t('table.row_update_format')\n  )\n}\n"
  },
  {
    "path": "src/modules/filelist/virtualized/AddFolderRow.jsx",
    "content": "import React from 'react'\n\nimport FilenameInput from '@/modules/filelist/FilenameInput'\nimport FileIconMime from '@/modules/filelist/icons/FileIconMime'\n\nconst AddFolderRow = ({ onSubmit, onAbort }) => {\n  return (\n    <div className=\"u-flex u-flex-items-center\">\n      <FileIconMime file={{ type: 'directory' }} size={35} />\n      <FilenameInput\n        className=\"u-ml-half u-m-half\"\n        onSubmit={onSubmit}\n        onAbort={onAbort}\n      />\n    </div>\n  )\n}\n\nexport default AddFolderRow\n"
  },
  {
    "path": "src/modules/filelist/virtualized/GridFile.jsx",
    "content": "import cx from 'classnames'\nimport { filesize } from 'filesize'\nimport get from 'lodash/get'\nimport PropTypes from 'prop-types'\nimport React, { useState, useRef } from 'react'\nimport { useSelector } from 'react-redux'\n\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport Box from 'cozy-ui/transpiled/react/Box'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport {\n  SelectBox,\n  FileName,\n  Status,\n  FileAction,\n  SharingShortcutBadge\n} from '../cells'\n\nimport styles from '@/styles/filelist.styl'\n\nimport { useClipboardContext } from '@/contexts/ClipboardProvider'\nimport { ActionMenuWithHeader } from '@/modules/actionmenu/ActionMenuWithHeader'\nimport { getContextMenuActions } from '@/modules/actions/helpers'\nimport { extraColumnsPropTypes } from '@/modules/certifications'\nimport {\n  isRenaming as isRenamingReducer,\n  getRenamingFile\n} from '@/modules/drive/rename'\nimport FileOpener from '@/modules/filelist/FileOpener'\nimport FileThumbnail from '@/modules/filelist/icons/FileThumbnail'\nimport { useFormattedUpdatedAt } from '@/modules/filelist/useFormattedUpdatedAt'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\nconst GridFile = ({\n  t,\n  attributes,\n  actions,\n  isRenaming,\n  withSelectionCheckbox,\n  withFilePath,\n  disabled,\n  refreshFolderContent,\n  isInSyncFromSharing,\n  breakpoints: { isMobile },\n  disableSelection = false,\n  canInteractWith,\n  onContextMenu,\n  isOver,\n  onInteractWithFile\n}) => {\n  const [actionMenuVisible, setActionMenuVisible] = useState(false)\n  const filerowMenuToggleRef = useRef()\n  const { toggleSelectedItem, isItemSelected, isSelectionBarVisible } =\n    useSelectionContext()\n  const { isItemCut } = useClipboardContext()\n  const { isNew } = useNewItemHighlightContext()\n\n  const toggleActionMenu = () => {\n    if (actionMenuVisible) return hideActionMenu()\n    else showActionMenu()\n  }\n  const showActionMenu = () => {\n    setActionMenuVisible(true)\n  }\n\n  const hideActionMenu = () => {\n    setActionMenuVisible(false)\n  }\n\n  const toggle = e => {\n    toggleSelectedItem(attributes)\n    onInteractWithFile?.(attributes?._id, e)\n  }\n\n  const isRowDisabledOrInSyncFromSharing = disabled || isInSyncFromSharing\n\n  const selected = isItemSelected(attributes._id)\n  const isCut = isItemCut(attributes._id)\n\n  const formattedSize =\n    !isDirectory(attributes) && attributes.size\n      ? filesize(attributes.size, { base: 10 })\n      : undefined\n\n  const updatedAt = attributes.updated_at || attributes.created_at\n  const formattedUpdatedAt = useFormattedUpdatedAt(updatedAt)\n\n  // We don't allow any action on shared drives and trash\n  // because they are magic folder created by the stack\n  let canInteractWithFile =\n    attributes._id &&\n    attributes._id !== 'io.cozy.files.shared-drives-dir' &&\n    !attributes._id.endsWith('.trash-dir')\n  if (typeof canInteractWith === 'function') {\n    canInteractWithFile &&= canInteractWith(attributes)\n  }\n\n  const contextMenuActions = getContextMenuActions(actions)\n\n  return (\n    <Box\n      display=\"block\"\n      borderColor=\"var(--dividerColor)\"\n      borderRadius={8}\n      padding={2}\n      data-file-id={attributes._id}\n      className={cx(\n        styles['fil-content-column'],\n        styles['fil-content-column-virtualized'],\n        {\n          [styles['fil-content-column-selected']]:\n            selected || isNew(attributes),\n          [styles['fil-content-column-actioned']]: actionMenuVisible || isOver,\n          [styles['fil-content-body--selectable']]: isSelectionBarVisible,\n          [styles['fil-content-row-disabled']]: isCut\n        }\n      )}\n      onContextMenu={onContextMenu}\n    >\n      <SelectBox\n        viewType=\"grid\"\n        withSelectionCheckbox={\n          withSelectionCheckbox && contextMenuActions?.length > 0\n        }\n        selected={selected}\n        onClick={e => toggle(e)}\n        disabled={\n          !canInteractWithFile ||\n          isRowDisabledOrInSyncFromSharing ||\n          disableSelection\n        }\n      />\n      <FileOpener\n        file={attributes}\n        disabled={\n          isRowDisabledOrInSyncFromSharing || isCut || actionMenuVisible\n        }\n        toggle={toggle}\n        onInteractWithFile={onInteractWithFile}\n        isRenaming={isRenaming}\n      >\n        <div\n          className={cx(\n            styles['fil-content-cell'],\n            styles['fil-file-thumbnail'],\n            styles['fil-content-grid-view'],\n            {\n              'u-pl-0': !isMobile\n            }\n          )}\n        >\n          <FileThumbnail\n            file={attributes}\n            size={96}\n            isInSyncFromSharing={isInSyncFromSharing}\n            showSharedBadge={isMobile}\n            componentsProps={{\n              sharedBadge: {\n                className: styles['fil-content-shared']\n              }\n            }}\n          />\n          <Status\n            file={attributes}\n            disabled={isRowDisabledOrInSyncFromSharing}\n            isInSyncFromSharing={isInSyncFromSharing}\n          />\n        </div>\n        <FileName\n          attributes={attributes}\n          isRenaming={isRenaming}\n          interactive={!isRowDisabledOrInSyncFromSharing}\n          withFilePath={withFilePath}\n          isMobile={isMobile}\n          formattedSize={formattedSize}\n          formattedUpdatedAt={formattedUpdatedAt}\n          refreshFolderContent={refreshFolderContent}\n          isInSyncFromSharing={isInSyncFromSharing}\n        />\n        <SharingShortcutBadge file={attributes} />\n      </FileOpener>\n      {contextMenuActions && canInteractWithFile && (\n        <FileAction\n          t={t}\n          ref={filerowMenuToggleRef}\n          disabled={isRowDisabledOrInSyncFromSharing || isCut}\n          isInSyncFromSharing={isInSyncFromSharing}\n          onClick={() => {\n            toggleActionMenu()\n          }}\n        />\n      )}\n      {contextMenuActions && actionMenuVisible && (\n        <ActionMenuWithHeader\n          file={attributes}\n          anchorElRef={filerowMenuToggleRef}\n          actions={contextMenuActions}\n          onClose={hideActionMenu}\n        />\n      )}\n    </Box>\n  )\n}\n\nGridFile.propTypes = {\n  t: PropTypes.func,\n  attributes: PropTypes.object.isRequired,\n  actions: PropTypes.array,\n  isRenaming: PropTypes.bool,\n  withSelectionCheckbox: PropTypes.bool.isRequired,\n  withFilePath: PropTypes.bool,\n  onContextMenu: PropTypes.func,\n  /** Disables row actions */\n  disabled: PropTypes.bool,\n  /** Apply disabled style on row */\n  breakpoints: PropTypes.object.isRequired,\n  refreshFolderContent: PropTypes.func,\n  isInSyncFromSharing: PropTypes.bool,\n  extraColumns: extraColumnsPropTypes,\n  /** Disables the ability to select a file */\n  disableSelection: PropTypes.bool,\n  isOver: PropTypes.bool\n}\n\nexport const DumbGridFile = props => {\n  const { t } = useI18n()\n  const breakpoints = useBreakpoints()\n\n  return <GridFile t={t} breakpoints={breakpoints} {...props} />\n}\n\nexport const GridFileWithSelection = props => {\n  const isRenaming = useSelector(\n    state =>\n      isRenamingReducer(state) &&\n      get(getRenamingFile(state), 'id') === props.attributes.id\n  )\n\n  return <DumbGridFile isRenaming={isRenaming} {...props} />\n}\n"
  },
  {
    "path": "src/modules/filelist/virtualized/cells/Cell.jsx",
    "content": "import { filesize } from 'filesize'\nimport get from 'lodash/get'\nimport React, { useContext, useReducer, useRef } from 'react'\nimport { useSelector } from 'react-redux'\n\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport { isSharingShortcut } from 'cozy-client/dist/models/file'\nimport { useVaultClient } from 'cozy-keys-lib'\nimport { useSharingContext } from 'cozy-sharing'\n\nimport AcceptingSharingContext from '@/lib/AcceptingSharingContext'\nimport { ActionMenuWithHeader } from '@/modules/actionmenu/ActionMenuWithHeader'\nimport { getContextMenuActions } from '@/modules/actions/helpers'\nimport { filterActionsByPolicy } from '@/modules/actions/policies'\nimport {\n  isRenaming as isRenamingSelector,\n  getRenamingFile\n} from '@/modules/drive/rename'\nimport AddFolder from '@/modules/filelist/AddFolder'\nimport FileOpener from '@/modules/filelist/FileOpener'\nimport { useFormattedUpdatedAt } from '@/modules/filelist/useFormattedUpdatedAt'\nimport FileAction from '@/modules/filelist/virtualized/cells/FileAction'\nimport FileName from '@/modules/filelist/virtualized/cells/FileName'\nimport LastUpdate from '@/modules/filelist/virtualized/cells/LastUpdate'\nimport Share from '@/modules/filelist/virtualized/cells/Share'\nimport Size from '@/modules/filelist/virtualized/cells/Size'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport { isReferencedByShareInSharingContext } from '@/modules/views/Folder/syncHelpers'\n\nconst Cell = ({\n  row,\n  column,\n  cell,\n  currentFolderId,\n  withFilePath,\n  actions,\n  onInteractWithFile,\n  refreshFolderContent,\n  driveId\n}) => {\n  const vaultClient = useVaultClient()\n\n  const { sharingsValue } = useContext(AcceptingSharingContext)\n  const { byDocId } = useSharingContext()\n  const filerowMenuToggleRef = useRef()\n  const { toggleSelectedItem } = useSelectionContext()\n\n  const [showActionMenu, toggleShowActionMenu] = useReducer(\n    state => !state,\n    false\n  )\n  const isRenaming = useSelector(\n    state =>\n      isRenamingSelector(state) && get(getRenamingFile(state), 'id') === row.id\n  )\n\n  const updatedAt = row.updated_at || row.created_at\n  const formattedUpdatedAt = useFormattedUpdatedAt(updatedAt)\n\n  if (row.type === 'tempDirectory') {\n    if (column.id === 'name') {\n      return (\n        <AddFolder\n          vaultClient={vaultClient}\n          currentFolderId={currentFolderId}\n          refreshFolderContent={refreshFolderContent}\n          driveId={driveId}\n        />\n      )\n    }\n\n    if (column.id === 'menu') {\n      return null\n    }\n\n    return '—'\n  }\n\n  const formattedSize =\n    !isDirectory(row) && row.size ? filesize(row.size, { base: 10 }) : undefined\n  const isSharingContextEmpty = Object.keys(sharingsValue).length <= 0\n  const isInSyncFromSharing =\n    !isSharingContextEmpty &&\n    isSharingShortcut(row) &&\n    isReferencedByShareInSharingContext(row, sharingsValue)\n\n  if (column.id === 'name') {\n    if (!cell) {\n      return '—'\n    }\n\n    const toggle = e => {\n      e.stopPropagation()\n      toggleSelectedItem(row)\n    }\n\n    return (\n      <FileOpener\n        file={row}\n        disabled={isInSyncFromSharing || showActionMenu}\n        toggle={toggle}\n        isRenaming={isRenaming}\n        onInteractWithFile={onInteractWithFile}\n      >\n        <FileName\n          attributes={row}\n          isRenaming={isRenaming}\n          interactive={!isInSyncFromSharing}\n          withFilePath={withFilePath}\n          formattedSize={formattedSize}\n          formattedUpdatedAt={formattedUpdatedAt}\n          refreshFolderContent={refreshFolderContent}\n          isInSyncFromSharing={isInSyncFromSharing}\n        />\n      </FileOpener>\n    )\n  }\n\n  if (column.id === 'updated_at') {\n    if (!cell) {\n      return '—'\n    }\n\n    return <LastUpdate date={cell} formatted={formattedUpdatedAt} />\n  }\n\n  if (column.id === 'size') {\n    if (!cell) {\n      return '—'\n    }\n\n    return <Size filesize={formattedSize} />\n  }\n\n  if (column.id === 'share') {\n    const isShared = byDocId[row.id] !== undefined\n\n    if (isInSyncFromSharing || !isShared) {\n      return '—'\n    }\n\n    return <Share row={row} isInSyncFromSharing={isInSyncFromSharing} />\n  }\n\n  if (column.id === 'menu') {\n    // We don't allow any action on shared drives and trash\n    // because they are magic folder created by the stack\n    const canInteractWithFile =\n      row._id &&\n      row._id !== 'io.cozy.files.shared-drives-dir' &&\n      !row._id.endsWith('.trash-dir')\n\n    if (!actions || !canInteractWithFile) {\n      return null\n    }\n\n    const filteredActions = filterActionsByPolicy(actions, [row])\n    const contextMenuActions = getContextMenuActions(filteredActions)\n\n    return (\n      <>\n        <FileAction\n          file={row}\n          ref={filerowMenuToggleRef}\n          disabled={isInSyncFromSharing}\n          onClick={toggleShowActionMenu}\n        />\n        {contextMenuActions && showActionMenu && (\n          <ActionMenuWithHeader\n            file={row}\n            anchorElRef={filerowMenuToggleRef}\n            actions={contextMenuActions}\n            onClose={toggleShowActionMenu}\n          />\n        )}\n      </>\n    )\n  }\n\n  return <>{cell}</>\n}\n\nconst CellMemo = React.memo(Cell)\n\nconst CellWrapper = ({\n  row,\n  column,\n  cell,\n  currentFolderId,\n  withFilePath,\n  actions,\n  onInteractWithFile,\n  refreshFolderContent,\n  driveId\n}) => {\n  return (\n    <CellMemo\n      row={row}\n      column={column}\n      cell={cell}\n      currentFolderId={currentFolderId}\n      withFilePath={withFilePath}\n      actions={actions}\n      onInteractWithFile={onInteractWithFile}\n      refreshFolderContent={refreshFolderContent}\n      driveId={driveId}\n    />\n  )\n}\n\nexport default CellWrapper\n"
  },
  {
    "path": "src/modules/filelist/virtualized/cells/FileAction.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport DotsIcon from 'cozy-ui/transpiled/react/Icons/Dots'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport { useI18n } from 'twake-i18n'\n\nconst FileAction = forwardRef(function FileAction({ onClick, disabled }, ref) {\n  const { t } = useI18n()\n  return (\n    <ListItemIcon>\n      <IconButton\n        ref={ref}\n        onClick={onClick}\n        disabled={disabled}\n        arial-label={t('Toolbar.more')}\n      >\n        <Icon icon={DotsIcon} />\n      </IconButton>\n    </ListItemIcon>\n  )\n})\n\nexport default FileAction\n"
  },
  {
    "path": "src/modules/filelist/virtualized/cells/FileName.jsx",
    "content": "import React from 'react'\n\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport Filename from 'cozy-ui/transpiled/react/Filename'\nimport { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/filelist.styl'\n\nimport { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext'\nimport RenameInput from '@/modules/drive/RenameInput'\nimport {\n  getFileNameAndExtension,\n  isInfected,\n  makeParentFolderPath\n} from '@/modules/filelist/helpers'\nimport FileThumbnail from '@/modules/filelist/icons/FileThumbnail'\nimport FileNamePath from '@/modules/filelist/virtualized/cells/FileNamePath'\n\nconst FileThumbnailComponent = ({ file, isInSyncFromSharing, isMobile }) => {\n  const { isBigThumbnail } = useThumbnailSizeContext()\n\n  return (\n    <div className=\"u-pl-half\">\n      <FileThumbnail\n        file={file}\n        size={isBigThumbnail ? 96 : 32}\n        isInSyncFromSharing={isInSyncFromSharing}\n        showSharedBadge={isMobile}\n        componentsProps={{\n          sharedBadge: {\n            className: styles['fil-content-shared-vz']\n          }\n        }}\n      />\n    </div>\n  )\n}\n\nconst FileName = ({\n  attributes,\n  isRenaming,\n  withFilePath,\n  formattedSize,\n  formattedUpdatedAt,\n  refreshFolderContent,\n  isInSyncFromSharing\n}) => {\n  const { t } = useI18n()\n  const { title, filename, extension } = getFileNameAndExtension(attributes, t)\n  const { isMobile } = useBreakpoints()\n\n  const parentFolderPath = makeParentFolderPath(attributes)\n  const hidePath = withFilePath\n    ? !parentFolderPath\n    : isDirectory(attributes) || !isMobile\n\n  const infected = isInfected(attributes)\n\n  if (isRenaming) {\n    return (\n      <div className=\"u-flex\">\n        <div className=\"u-mr-half\">\n          <FileThumbnailComponent\n            file={attributes}\n            isInSyncFromSharing={isInSyncFromSharing}\n            isMobile={isMobile}\n          />\n        </div>\n        <RenameInput\n          style={{ display: 'flex' }}\n          className=\"u-flex-items-center\"\n          file={attributes}\n          refreshFolderContent={refreshFolderContent}\n        />\n      </div>\n    )\n  }\n\n  return (\n    <span title={infected ? t('antivirus.infectedFile') : title}>\n      <Filename\n        icon={\n          <FileThumbnailComponent\n            file={attributes}\n            isInSyncFromSharing={isInSyncFromSharing}\n            isMobile={isMobile}\n          />\n        }\n        variant=\"body1\"\n        filename={filename}\n        extension={extension}\n        midEllipsis\n        path={\n          hidePath ? undefined : (\n            <FileNamePath\n              attributes={attributes}\n              withFilePath={withFilePath}\n              formattedSize={formattedSize}\n              formattedUpdatedAt={formattedUpdatedAt}\n              parentFolderPath={parentFolderPath}\n            />\n          )\n        }\n      />\n    </span>\n  )\n}\n\nexport default FileName\n"
  },
  {
    "path": "src/modules/filelist/virtualized/cells/FileNamePath.jsx",
    "content": "import React from 'react'\nimport { Link } from 'react-router-dom'\n\nimport MidEllipsis from 'cozy-ui/transpiled/react/MidEllipsis'\nimport { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/filelist.styl'\n\nimport { SHARINGS_VIEW_ROUTE } from '@/constants/config'\nimport CertificationsIcons from '@/modules/filelist/cells/CertificationsIcons.jsx'\nimport { getFileNameAndExtension } from '@/modules/filelist/helpers'\nimport { getFolderPath } from '@/modules/routeUtils'\n\nconst FileNamePath = ({\n  attributes,\n  withFilePath,\n  formattedSize,\n  formattedUpdatedAt,\n  parentFolderPath\n}) => {\n  const { isMobile } = useBreakpoints()\n  const { t } = useI18n()\n  const { filename, extension } = getFileNameAndExtension(attributes, t)\n\n  if (!withFilePath) {\n    return (\n      <div className={styles['fil-file-infos']}>\n        {`${formattedUpdatedAt}${formattedSize ? ` - ${formattedSize}` : ''}`}\n        <CertificationsIcons attributes={attributes} />\n      </div>\n    )\n  }\n\n  if (isMobile) {\n    return (\n      <div\n        className={styles['fil-file-description']}\n        title={filename + extension}\n      >\n        <MidEllipsis\n          className={styles['fil-file-description--path']}\n          text={parentFolderPath}\n        />\n        <CertificationsIcons attributes={attributes} />\n      </div>\n    )\n  }\n  const to = attributes.driveId\n    ? SHARINGS_VIEW_ROUTE\n    : getFolderPath(attributes.dir_id)\n\n  return (\n    <Link\n      to={to}\n      // Please do not modify the className as it is used in event handling, see FileOpener\n      className={styles['fil-file-path']}\n    >\n      <MidEllipsis text={parentFolderPath} />\n    </Link>\n  )\n}\n\nexport default FileNamePath\n"
  },
  {
    "path": "src/modules/filelist/virtualized/cells/LastUpdate.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { useI18n } from 'twake-i18n'\n\nconst LastUpdate = ({ date, formatted }) => {\n  const { f, t } = useI18n()\n\n  return (\n    <time\n      dateTime={date}\n      {...(formatted && { title: f(date, t('LastUpdate.titleFormat')) })}\n    >\n      {formatted}\n    </time>\n  )\n}\n\nLastUpdate.propTypes = {\n  date: PropTypes.string,\n  formatted: PropTypes.string\n}\n\nexport default React.memo(LastUpdate)\n"
  },
  {
    "path": "src/modules/filelist/virtualized/cells/Share.jsx",
    "content": "import React from 'react'\n\nimport ShareContent from './ShareContent'\nimport SharingShortcutBadge from './SharingShortcutBadge'\n\nconst Share = ({ row, isRowDisabledOrInSyncFromSharing }) => {\n  return (\n    <>\n      <ShareContent file={row} disabled={isRowDisabledOrInSyncFromSharing} />\n      <SharingShortcutBadge file={row} />\n    </>\n  )\n}\n\nexport default Share\n"
  },
  {
    "path": "src/modules/filelist/virtualized/cells/ShareContent.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\nimport { useNavigate, useLocation } from 'react-router-dom'\n\nimport { SharedStatus } from 'cozy-sharing'\n\nimport styles from '@/styles/filelist.styl'\n\nimport { joinPath } from '@/lib/path'\n\nconst ShareContent = ({ file, disabled }) => {\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n\n  const handleClick = e => {\n    // Avoid to trigger row click from FileOpener\n    e.preventDefault()\n    e.stopPropagation()\n\n    if (!disabled) {\n      // should be only disabled\n      navigate(joinPath(pathname, `file/${file._id}/share`))\n    }\n  }\n\n  return (\n    <div\n      className={cx(styles['fil-content-sharestatus'], {\n        [styles['fil-content-sharestatus--disabled']]: disabled\n      })}\n    >\n      <SharedStatus onClick={handleClick} docId={file.id} />\n    </div>\n  )\n}\n\nexport default ShareContent\n"
  },
  {
    "path": "src/modules/filelist/virtualized/cells/SharingShortcutBadge.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { isSharingShortcutNew } from 'cozy-client/dist/models/file'\nimport Avatar from 'cozy-ui/transpiled/react/Avatar'\nimport { useI18n } from 'twake-i18n'\n\nconst SharingShortcutBadge = ({ file }) => {\n  const { t } = useI18n()\n\n  if (isSharingShortcutNew(file)) {\n    return (\n      <Avatar color=\"var(--errorColor)\" textColor=\"var(--white)\" size=\"xs\">\n        <span\n          style={{ fontSize: '11px', lineHeight: '1rem' }}\n          aria-label={t('table.row_sharing_shortcut_aria_label')}\n        >\n          1\n        </span>\n      </Avatar>\n    )\n  }\n\n  return null\n}\n\nSharingShortcutBadge.propTypes = {\n  file: PropTypes.object,\n  isInSyncFromSharing: PropTypes.bool\n}\n\nexport default SharingShortcutBadge\n"
  },
  {
    "path": "src/modules/filelist/virtualized/cells/Size.jsx",
    "content": "import React from 'react'\n\nconst _Size = ({ filesize }) => <>{filesize}</>\n\nconst Size = React.memo(_Size)\n\nexport default Size\n"
  },
  {
    "path": "src/modules/folder/components/FolderBody.jsx",
    "content": "import cx from 'classnames'\nimport React, { useCallback } from 'react'\n\nimport { useVaultClient } from 'cozy-keys-lib'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport styles from '@/styles/folder-view.styl'\n\nimport { EmptyDrive } from '@/components/Error/Empty'\nimport Oops from '@/components/Error/Oops'\nimport RightClickFileMenu from '@/components/RightClick/RightClickFileMenu'\nimport { useFolderSort } from '@/hooks'\nimport { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext'\nimport { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'\nimport AddFolder from '@/modules/filelist/AddFolder'\nimport { FileWithSelection as File } from '@/modules/filelist/File'\nimport { FileList } from '@/modules/filelist/FileList'\nimport FileListBody from '@/modules/filelist/FileListBody'\nimport { FileListHeader } from '@/modules/filelist/FileListHeader'\nimport FileListRowsPlaceholder from '@/modules/filelist/FileListRowsPlaceholder'\nimport LoadMore from '@/modules/filelist/LoadMoreV2'\nimport { useNeedsToWait } from '@/modules/folder/hooks/useNeedsToWait'\nimport { useScrollToTop } from '@/modules/folder/hooks/useScrollToTop'\nimport SelectionBar from '@/modules/selection/SelectionBar'\n\n/**\n * Renders the body of a folder, displaying the list of files and folders within it.\n *\n * @component\n * @param {Object} props - The component props.\n * @param {string} props.folderId - The ID of the folder.\n * @param {Array} props.queryResults - The results of the queries for the folder content.\n * @param {Object} [props.actions] - The actions available for the folder.\n * @param {import('modules/certifications/useExtraColumns').ExtraColumn[]} props.extraColumns - The extra columns to display in the file list.\n * @param {boolean} [props.canSort] - Indicates whether sorting is enabled for the file list.\n * @param {Function} [props.refreshFolderContent] - The function to refresh the folder content.\n * @param {boolean} [props.withFilePath] - Indicates whether to display the file path.\n * @param {boolean} [props.isInSyncFromSharing] - Indicates whether the folder is in sync from sharing.\n * @param {Function} [props.renderEmptyComponent] - The function to render the empty component.\n * @param {Function} [props.canInteractWith] - Indicates whether the user can interact with the file.\n */\nconst FolderBody = ({\n  folderId,\n  queryResults,\n  actions,\n  extraColumns,\n  canSort,\n  refreshFolderContent,\n  withFilePath,\n  isInSyncFromSharing,\n  renderEmptyComponent = () => {\n    return <EmptyDrive />\n  },\n  canInteractWith,\n  driveId\n}) => {\n  const vaultClient = useVaultClient()\n  const { isDesktop } = useBreakpoints()\n\n  useScrollToTop(folderId)\n\n  const [sortOrder, setSortOrder, isSettingsLoaded] = useFolderSort(folderId)\n\n  const isError = queryResults.some(query => query.fetchStatus === 'failed')\n  const hasData =\n    !isError && queryResults.some(query => query.data && query.data.length > 0)\n  const isLoading =\n    !hasData &&\n    queryResults.some(\n      query => query.fetchStatus === 'loading' && !query.lastUpdate\n    ) &&\n    !isSettingsLoaded\n  const isEmpty = !isError && !isLoading && !hasData\n  const needsToWait = useNeedsToWait({ isLoading })\n\n  const { isBigThumbnail } = useThumbnailSizeContext()\n  const { viewType, switchView } = useViewSwitcherContext()\n\n  const changeSortOrder = useCallback(\n    (folderId_legacy, attribute, order) => setSortOrder({ attribute, order }),\n    [setSortOrder]\n  )\n\n  return (\n    <>\n      <SelectionBar actions={actions} />\n      <FileList>\n        {hasData ? (\n          <FileListHeader\n            folderId={null}\n            canSort={canSort}\n            sort={sortOrder}\n            onFolderSort={changeSortOrder}\n            switchViewType={switchView}\n            viewType={viewType}\n          />\n        ) : null}\n        <FileListBody selectionModeActive={false}>\n          {!hasData && !needsToWait && (\n            <div\n              className={cx(\n                viewType === 'grid' ? styles['fil-folder-body-grid'] : '',\n                {\n                  'u-ov-hidden': !isDesktop\n                }\n              )}\n            >\n              <AddFolder\n                vaultClient={vaultClient}\n                refreshFolderContent={refreshFolderContent}\n                extraColumns={extraColumns}\n                currentFolderId={folderId}\n                driveId={driveId}\n              />\n            </div>\n          )}\n          {isError ? <Oops /> : null}\n          {isLoading || needsToWait ? <FileListRowsPlaceholder /> : null}\n          {isEmpty ? renderEmptyComponent() : null}\n          {hasData && !needsToWait ? (\n            <div\n              className={cx(\n                viewType === 'grid' ? styles['fil-folder-body-grid'] : '',\n                {\n                  'u-ov-hidden': !isDesktop\n                }\n              )}\n            >\n              <AddFolder\n                vaultClient={vaultClient}\n                refreshFolderContent={refreshFolderContent}\n                extraColumns={extraColumns}\n                currentFolderId={folderId}\n                driveId={driveId}\n              />\n              {queryResults.map((query, queryIndex) => (\n                <React.Fragment key={queryIndex}>\n                  {query.data.map(file => {\n                    return (\n                      <RightClickFileMenu\n                        key={file._id}\n                        doc={file}\n                        actions={actions}\n                      >\n                        <File\n                          key={file._id}\n                          attributes={file}\n                          withFilePath={withFilePath}\n                          thumbnailSizeBig={isBigThumbnail}\n                          actions={actions}\n                          refreshFolderContent={refreshFolderContent}\n                          isInSyncFromSharing={isInSyncFromSharing}\n                          extraColumns={extraColumns}\n                          withSelectionCheckbox\n                          canInteractWith={canInteractWith}\n                        />\n                      </RightClickFileMenu>\n                    )\n                  })}\n                  {query.hasMore && <LoadMore fetchMore={query.fetchMore} />}\n                </React.Fragment>\n              ))}\n            </div>\n          ) : null}\n        </FileListBody>\n      </FileList>\n    </>\n  )\n}\n\nexport { FolderBody }\n"
  },
  {
    "path": "src/modules/folder/hooks/useNeedsToWait.jsx",
    "content": "import { useEffect, useState } from 'react'\n\n/**\n * When we mount the component when we already have data in cache,\n * the mount is time consuming since we'll render at least 100 lines\n * of File.\n *\n * React seems to batch together the fact that :\n * - we change a route\n * - we want to render 100 files\n * resulting in a non smooth transition between views (Drive / Recent / ...)\n *\n * In order to bypass this batch, we use a state to first display a much\n * more simpler component and then the files\n */\nconst useNeedsToWait = ({ isLoading }) => {\n  const [needsToWait, setNeedsToWait] = useState(true)\n  useEffect(() => {\n    let timeout = null\n    if (!isLoading) {\n      timeout = setTimeout(() => {\n        setNeedsToWait(false)\n      }, 50)\n    }\n    return () => clearTimeout(timeout)\n  }, [isLoading])\n  return needsToWait\n}\n\nexport { useNeedsToWait }\n"
  },
  {
    "path": "src/modules/folder/hooks/useScrollToTop.jsx",
    "content": "import { useEffect } from 'react'\n\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\n/**\n *  Since we are not able to restore the scroll correctly,\n * and force the scroll to top every time we change the\n * current folder. This is to avoid this kind of weird\n * behavior:\n * - If I go to a sub-folder, if this subfolder has a lot\n * of data and I scrolled down until the bottom. If I go\n * back, then my folder will also be scrolled down.\n *\n * This is an ugly hack, yeah.\n * */\nconst useScrollToTop = folderId => {\n  const { isDesktop } = useBreakpoints()\n  useEffect(() => {\n    if (isDesktop) {\n      const scrollable = document.querySelectorAll(\n        '[data-testid=fil-content-body]'\n      )[0]\n      if (scrollable) {\n        scrollable.scroll({ top: 0 })\n      }\n    } else {\n      window.scroll({ top: 0 })\n    }\n  }, [isDesktop, folderId])\n}\n\nexport { useScrollToTop }\n"
  },
  {
    "path": "src/modules/layout/DummyLayout.tsx",
    "content": "import React from 'react'\n\nimport Sprite from 'cozy-ui/transpiled/react/Icon/Sprite'\nimport { Layout } from 'cozy-ui/transpiled/react/Layout'\n\nconst DummyLayout: React.FC = ({ children }) => {\n  return (\n    <Layout monoColumn={true}>\n      {children}\n      <Sprite />\n    </Layout>\n  )\n}\n\nexport { DummyLayout }\n"
  },
  {
    "path": "src/modules/layout/Layout.jsx",
    "content": "import React, { useEffect } from 'react'\nimport { useDispatch, useSelector } from 'react-redux'\nimport { Outlet, useNavigate } from 'react-router-dom'\n\nimport { BarComponent } from 'cozy-bar'\nimport CozyDevtools from 'cozy-devtools'\nimport flag from 'cozy-flags'\nimport FlagSwitcher from 'cozy-flags/dist/FlagSwitcher'\nimport { useSharingContext } from 'cozy-sharing'\nimport Sprite from 'cozy-ui/transpiled/react/Icon/Sprite'\nimport { Layout as LayoutUI } from 'cozy-ui/transpiled/react/Layout'\nimport Sidebar from 'cozy-ui/transpiled/react/Sidebar'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport Storage from 'cozy-ui-plus/dist/Storage'\nimport { useI18n } from 'twake-i18n'\n\nimport Drive from '@/components/Icons/Drive'\nimport DriveText from '@/components/Icons/DriveText'\nimport ButtonClient from '@/components/pushClient/Button'\nimport { ROOT_DIR_ID } from '@/constants/config'\nimport { useDisplayedFolder } from '@/hooks'\nimport { initFlags } from '@/lib/flags'\nimport AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'\nimport AddButton from '@/modules/drive/Toolbar/components/AddButton'\nimport Nav from '@/modules/navigation/Nav'\nimport { NavProvider, useNavContext } from '@/modules/navigation/NavContext'\nimport {\n  wasOperationRedirected,\n  RESET_OPERATION_REDIRECTED\n} from '@/modules/navigation/duck/reducer'\nimport { SelectionProvider } from '@/modules/selection/SelectionProvider'\nimport { NewItemHighlightProvider } from '@/modules/upload/NewItemHighlightProvider'\nimport UploadButton from '@/modules/upload/UploadButton'\nimport UploadQueue from '@/modules/upload/UploadQueue'\n\ninitFlags()\n\nconst LayoutContent = () => {\n  const navigate = useNavigate()\n  const dispatch = useDispatch()\n  const { isMobile, isDesktop } = useBreakpoints()\n  const { displayedFolder } = useDisplayedFolder()\n  const { hasWriteAccess } = useSharingContext()\n  const { t } = useI18n()\n\n  const shouldRedirect = useSelector(wasOperationRedirected)\n  const [, setLastClicked] = useNavContext()\n\n  useEffect(() => {\n    if (shouldRedirect) {\n      // Update lastClicked state to ensure sidebar shows the correct active item\n      setLastClicked(`/folder/${ROOT_DIR_ID}`)\n      navigate(`/folder/${ROOT_DIR_ID}`)\n      dispatch({ type: RESET_OPERATION_REDIRECTED })\n    }\n  }, [shouldRedirect, navigate, dispatch, setLastClicked])\n\n  const isFolderReadOnly = displayedFolder\n    ? !hasWriteAccess(displayedFolder._id, displayedFolder.driveId)\n    : false\n\n  return (\n    <LayoutUI onContextMenu={ev => ev.preventDefault()}>\n      <NewItemHighlightProvider>\n        <BarComponent\n          searchOptions={{ enabled: !isMobile }}\n          disableInternalStore\n          appIcon={Drive}\n          appTextIcon={DriveText}\n        />\n        <FlagSwitcher />\n        <Sidebar className=\"u-flex-justify-between\">\n          <div>\n            {isDesktop ? (\n              <div className=\"u-mh-1 u-mt-half\">\n                <AddMenuProvider\n                  canCreateFolder={true}\n                  canUpload={!isFolderReadOnly}\n                  disabled={false}\n                  displayedFolder={displayedFolder}\n                  isSelectionBarVisible={false}\n                  isReadOnly={isFolderReadOnly}\n                  componentsProps={{ AddMenu: { isUploadDisabled: true } }}\n                >\n                  <AddButton className=\"u-w-100 u-bdrs-6 u-mt-half u-mb-half u-fz-small\" />\n                </AddMenuProvider>\n                <UploadButton\n                  componentsProps={{\n                    button: { className: 'u-w-100 u-bdrs-6 u-fz-small' }\n                  }}\n                  label={t('upload.label')}\n                  displayedFolder={displayedFolder}\n                  disabled={isFolderReadOnly}\n                />\n              </div>\n            ) : null}\n            <Nav />\n          </div>\n          {isDesktop && (\n            <div>\n              <div className=\"u-p-1-half\">\n                <Storage />\n              </div>\n              <ButtonClient />\n            </div>\n          )}\n        </Sidebar>\n        <UploadQueue />\n        <SelectionProvider>\n          <Outlet />\n        </SelectionProvider>\n        <Sprite />\n        {flag('debug') && <CozyDevtools />}\n      </NewItemHighlightProvider>\n    </LayoutUI>\n  )\n}\n\nconst Layout = () => {\n  return (\n    <NavProvider>\n      <LayoutContent />\n    </NavProvider>\n  )\n}\n\nexport default Layout\n"
  },
  {
    "path": "src/modules/layout/Main.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { RealTimeQueries } from 'cozy-client'\nimport { Main as MainUI } from 'cozy-ui/transpiled/react/Layout'\n\nimport { MigrationProgressBanner } from '@/components/Migration/MigrationProgressBanner'\nimport PushBanner from '@/components/PushBanner'\nimport { NEXTCLOUD_MIGRATIONS_DOCTYPE } from '@/lib/doctypes'\n\nconst Main = ({ children, isPublic = false }) => (\n  <MainUI>\n    <PushBanner isPublic={isPublic} />\n    {!isPublic && (\n      <>\n        <RealTimeQueries doctype={NEXTCLOUD_MIGRATIONS_DOCTYPE} />\n        <MigrationProgressBanner />\n      </>\n    )}\n    {children}\n  </MainUI>\n)\n\nMain.propTypes = {\n  isPublic: PropTypes.bool,\n  children: PropTypes.array\n}\nexport default Main\n"
  },
  {
    "path": "src/modules/layout/Topbar.jsx",
    "content": "import classNames from 'classnames'\nimport React from 'react'\n\nimport styles from '@/styles/topbar.styl'\n\nconst Topbar = ({ children, hideOnMobile = true }) => (\n  <div\n    className={classNames('u-mb-1-half', styles['fil-topbar'], {\n      [styles['hidden-mobile']]: hideOnMobile\n    })}\n  >\n    {children}\n  </div>\n)\n\nexport default Topbar\n"
  },
  {
    "path": "src/modules/move/MoveInsideSharedFolderModal.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { useQuery } from 'cozy-client'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport { useI18n } from 'twake-i18n'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport { getEntriesTypeTranslated } from '@/lib/entries'\nimport {\n  buildFileOrFolderByIdQuery,\n  buildSharedDriveFileOrFolderByIdQuery\n} from '@/queries'\n\n/**\n * Alert the user when is trying to move a folder/file inside of a shared folder\n */\nconst MoveInsideSharedFolderModal = ({\n  entries,\n  folderId,\n  driveId,\n  onCancel,\n  onConfirm\n}) => {\n  const { t } = useI18n()\n\n  const folderQuery = driveId\n    ? buildSharedDriveFileOrFolderByIdQuery({ fileId: folderId, driveId })\n    : buildFileOrFolderByIdQuery(folderId)\n  const { fetchStatus, data } = useQuery(\n    folderQuery.definition,\n    folderQuery.options\n  )\n\n  if (fetchStatus === 'loaded') {\n    const type = getEntriesTypeTranslated(t, entries)\n\n    return (\n      <ConfirmDialog\n        open\n        title={t('Move.insideSharedFolder.title')}\n        content={t('Move.insideSharedFolder.content', {\n          destination: data.name,\n          source: entries[0].name,\n          type,\n          smart_count: entries.length\n        })}\n        actions={\n          <>\n            <Buttons\n              variant=\"secondary\"\n              label={t('Move.insideSharedFolder.cancel')}\n              onClick={onCancel}\n            />\n            <Buttons\n              label={t('Move.insideSharedFolder.confirm')}\n              onClick={onConfirm}\n            />\n          </>\n        }\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nMoveInsideSharedFolderModal.propTypes = {\n  /** List of files or folder to move */\n  entries: PropTypes.array.isRequired,\n  /** Function called when the user cancels the move action */\n  onCancel: PropTypes.func.isRequired,\n  /** Function called when the user confirms the move action */\n  onConfirm: PropTypes.func.isRequired\n}\n\nexport { MoveInsideSharedFolderModal }\n"
  },
  {
    "path": "src/modules/move/MoveModal.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React, { useState } from 'react'\n\nimport { useClient } from 'cozy-client'\nimport { useSharingContext } from 'cozy-sharing'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { useMove } from './hooks/useMove'\n\nimport { FolderPicker } from '@/components/FolderPicker/FolderPicker'\nimport logger from '@/lib/logger'\nimport { joinPath, getParentPath } from '@/lib/path'\nimport { MoveInsideSharedFolderModal } from '@/modules/move/MoveInsideSharedFolderModal'\nimport { MoveOutsideSharedFolderModal } from '@/modules/move/MoveOutsideSharedFolderModal'\nimport { MoveSharedFolderInsideAnotherModal } from '@/modules/move/MoveSharedFolderInsideAnotherModal'\nimport { hasOneOfEntriesShared } from '@/modules/move/helpers'\nimport { useCancelable } from '@/modules/move/hooks/useCancelable'\nimport { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'\nimport { executeMove } from '@/modules/paste'\n\n/**\n * Modal to move a folder to an other\n */\nconst MoveModal = ({\n  onClose,\n  currentFolder,\n  entries,\n  showNextcloudFolder,\n  onMovingSuccess,\n  isPublic,\n  showSharedDriveFolder,\n  driveId\n}) => {\n  const client = useClient()\n  const {\n    sharedPaths,\n    refresh: refreshSharing,\n    getSharedParentPath,\n    hasSharedParent,\n    isOwner,\n    revokeSelf,\n    revokeAllRecipients,\n    byDocId,\n    allLoaded\n  } = useSharingContext()\n  const { registerCancelable } = useCancelable()\n  const { showSuccess } = useMove({ entries })\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n\n  const [folderSelected, setFolderSelected] = useState(null)\n  const [isMoveInProgress, setMoveInProgress] = useState(false)\n  const [isMovingOutsideSharedFolder, setMovingOutsideSharedFolder] =\n    useState(false)\n  const [\n    isMovingSharedFolderInsideAnother,\n    setMovingSharedFolderInsideAnother\n  ] = useState(false)\n  const [isMovingInsideSharedFolder, setMovingInsideSharedFolder] =\n    useState(false)\n\n  const handleConfirm = async folder => {\n    setFolderSelected(folder)\n\n    const sharedParentPath = getSharedParentPath(entries[0].path)\n    const targetPath = joinPath(folder.path, entries[0].name)\n\n    const areMovedFilesShared = hasOneOfEntriesShared(entries, byDocId)\n    const isOriginParentShared = hasSharedParent(entries[0].path) || !!driveId\n    const isTargetShared =\n      hasSharedParent(targetPath) ||\n      (!!folder.driveId && folder.driveId !== driveId)\n    const isInsideSameSharedFolder =\n      (sharedParentPath && targetPath.startsWith(sharedParentPath)) ||\n      (!!folder.driveId && !!driveId && folder.driveId === driveId) ||\n      isPublic\n\n    if (isInsideSameSharedFolder) {\n      moveEntries(folder)\n      return\n    }\n\n    if (isOriginParentShared && !isTargetShared) {\n      setMovingOutsideSharedFolder(true)\n      return\n    }\n\n    if (!areMovedFilesShared && isTargetShared) {\n      setMovingInsideSharedFolder(true)\n      return\n    }\n\n    if (areMovedFilesShared && isTargetShared) {\n      setMovingSharedFolderInsideAnother(true)\n      return\n    }\n\n    moveEntries(folder)\n  }\n\n  const moveEntries = async folder => {\n    try {\n      setMoveInProgress(true)\n      const trashedFiles = []\n      const force = !sharedPaths.includes(folder.path)\n      await Promise.all(\n        entries.map(async entry => {\n          const moveResponse = await registerCancelable(\n            executeMove(client, entry, currentFolder, folder, force)\n          )\n          if (moveResponse.deleted) {\n            trashedFiles.push(moveResponse.deleted)\n          }\n        })\n      )\n\n      const isMovingInsideNextcloud =\n        folder._type === 'io.cozy.remote.nextcloud.files'\n\n      const isMovingOutsideNextcloud =\n        !isMovingInsideNextcloud &&\n        entries[0]._type === 'io.cozy.remote.nextcloud.files'\n\n      refreshNextcloudQueries({\n        isMovingInsideNextcloud,\n        isMovingOutsideNextcloud,\n        folder\n      })\n\n      showSuccess({\n        folder,\n        trashedFiles,\n        refreshSharing,\n        canCancel: !isMovingInsideNextcloud && !isMovingOutsideNextcloud\n      })\n\n      if (refreshSharing) refreshSharing()\n\n      onMovingSuccess?.()\n    } catch (e) {\n      logger.warn(e)\n      showAlert({\n        message: t('Move.error', { smart_count: entries.length }),\n        severity: 'error'\n      })\n    } finally {\n      setMoveInProgress(false)\n      onClose()\n    }\n  }\n\n  /**\n   * The content from nextcloud queries must be refreshed when moving files\n   * This is only a proxy to Nextcloud queries so we don't have real-time or mutations updates\n   */\n  const refreshNextcloudQueries = ({\n    isMovingOutsideNextcloud,\n    isMovingInsideNextcloud,\n    folder\n  }) => {\n    if (isMovingInsideNextcloud) {\n      client.resetQuery(\n        computeNextcloudFolderQueryId({\n          sourceAccount: folder.cozyMetadata.sourceAccount,\n          path: folder.path\n        })\n      )\n    }\n\n    if (isMovingOutsideNextcloud) {\n      client.resetQuery(\n        computeNextcloudFolderQueryId({\n          sourceAccount: entries[0].cozyMetadata.sourceAccount,\n          path: getParentPath(entries[0].path)\n        })\n      )\n    }\n  }\n\n  const handleCancelMovingOutside = () => {\n    setMovingOutsideSharedFolder(false)\n  }\n\n  const handleConfirmMovingOutside = () => {\n    setMovingOutsideSharedFolder(false)\n    moveEntries(folderSelected)\n  }\n\n  const handleCancelMovingInside = () => {\n    setMovingInsideSharedFolder(false)\n  }\n\n  const handleConfirmMovingInside = () => {\n    setMovingInsideSharedFolder(false)\n    moveEntries(folderSelected)\n  }\n\n  const handleMovingSharedFolderInsideAnother = async () => {\n    setMoveInProgress(true)\n    entries.forEach(async entry => {\n      if (byDocId[entry._id] !== undefined) {\n        if (isOwner(entry._id)) {\n          await revokeAllRecipients(entry)\n        } else {\n          await revokeSelf(entry)\n        }\n      }\n    })\n    refreshSharing()\n    moveEntries(folderSelected)\n    setMovingSharedFolderInsideAnother(false)\n  }\n\n  return (\n    <>\n      <FolderPicker\n        showNextcloudFolder={showNextcloudFolder}\n        showSharedDriveFolder={showSharedDriveFolder}\n        currentFolder={currentFolder}\n        entries={entries}\n        onConfirm={handleConfirm}\n        onClose={onClose}\n        isBusy={isMoveInProgress || (!isPublic && !allLoaded)}\n        isPublic={isPublic}\n      />\n      {isMovingOutsideSharedFolder ? (\n        <MoveOutsideSharedFolderModal\n          entries={entries}\n          onCancel={handleCancelMovingOutside}\n          onConfirm={handleConfirmMovingOutside}\n          driveId={driveId}\n        />\n      ) : null}\n      {isMovingSharedFolderInsideAnother ? (\n        <MoveSharedFolderInsideAnotherModal\n          entries={entries}\n          folderId={folderSelected._id}\n          driveId={folderSelected.driveId}\n          onCancel={() => setMovingSharedFolderInsideAnother(false)}\n          onConfirm={handleMovingSharedFolderInsideAnother}\n        />\n      ) : null}\n      {isMovingInsideSharedFolder ? (\n        <MoveInsideSharedFolderModal\n          onCancel={handleCancelMovingInside}\n          onConfirm={handleConfirmMovingInside}\n          entries={entries}\n          folderId={folderSelected._id}\n          driveId={folderSelected.driveId}\n        />\n      ) : null}\n    </>\n  )\n}\n\nMoveModal.propTypes = {\n  /** List of files or folder to move */\n  entries: PropTypes.array,\n  onMovingSuccess: PropTypes.func\n}\n\nexport { MoveModal }\n\nexport default MoveModal\n"
  },
  {
    "path": "src/modules/move/MoveModal.spec.jsx",
    "content": "import { render, screen, fireEvent, waitFor } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient, useQuery } from 'cozy-client'\nimport { move } from 'cozy-client/dist/models/file'\nimport { useSharingContext } from 'cozy-sharing'\n\nimport { MoveModal } from './MoveModal'\nimport AppLike from 'test/components/AppLike'\n\nimport { ROOT_DIR_ID } from '@/constants/config'\nimport { CozyFile } from '@/models'\n\njest.mock('cozy-sharing', () => ({\n  ...jest.requireActual('cozy-sharing'),\n  useSharingContext: jest.fn()\n}))\n\njest.mock('cozy-doctypes')\nCozyFile.doctype = 'io.cozy.files'\nconst onCloseSpy = jest.fn()\nconst refreshSpy = jest.fn()\n\njest.mock('cozy-client/dist/models/file', () => ({\n  move: jest.fn(),\n  isFile: jest.fn(),\n  moveRelateToSharedDrive: jest.fn()\n}))\n\njest.mock('cozy-client', () => ({\n  ...jest.requireActual('cozy-client'),\n  useQuery: jest.fn()\n}))\n\nCozyFile.splitFilename.mockImplementation(({ name }) => ({\n  filename: name,\n  extension: ''\n}))\n\njest.mock('components/FolderPicker/FolderPicker', () => ({\n  FolderPicker: ({ onConfirm, currentFolder, isBusy }) => {\n    const handleClick = () => {\n      onConfirm(currentFolder)\n    }\n\n    return (\n      <div>\n        <h1>{currentFolder.name}</h1>\n        <button onClick={handleClick} disabled={isBusy}>\n          Move\n        </button>\n        <button>Close</button>\n      </div>\n    )\n  }\n}))\n\ndescribe('MoveModal component', () => {\n  const defaultEntries = [\n    {\n      _id: 'bill_201901',\n      dir_id: 'bills',\n      name: 'bill_201901.pdf',\n      path: '/bills/bill_201901.pdf'\n    },\n    {\n      _id: 'bill_201902',\n      dir_id: 'bills',\n      name: 'bill_201902.pdf',\n      path: '/bills/bill_201902.pdf'\n    },\n    // shared file:\n    {\n      _id: 'bill_201903',\n      dir_id: 'bills',\n      name: 'bill_201903.pdf',\n      path: '/bills/bill_201903.pdf'\n    }\n  ]\n\n  const destinationFolder = {\n    id: 'destinationFolder',\n    _id: 'destinationFolder',\n    _type: 'io.cozy.files',\n    name: 'Destination Folder',\n    path: '/Destination Folder'\n  }\n\n  const mockClient = createMockClient({\n    queries: {\n      'moveOrImport-destinationFolder': {\n        doctype: 'io.cozy.files',\n        data: []\n      },\n      'io.cozy.files/destinationFolder': {\n        doctype: 'io.cozy.files',\n        data: [\n          {\n            _id: 'destinationFolder',\n            dir_id: ROOT_DIR_ID,\n            name: 'Destination Folder',\n            type: 'directory'\n          }\n        ]\n      },\n      'io.cozy.files/path/bills': {\n        doctype: 'io.cozy.files',\n        data: [\n          {\n            _id: 'bills',\n            dir_id: ROOT_DIR_ID,\n            name: 'Bills',\n            type: 'directory'\n          }\n        ]\n      }\n    }\n  })\n\n  const setup = ({\n    entries = defaultEntries,\n    sharedPaths = ['/sharedFolder'],\n    byDocId = {},\n    getSharedParentPath = () => null,\n    allLoaded = true,\n    sharingContext = {},\n    currentFolder = destinationFolder\n  } = {}) => {\n    const props = {\n      entries,\n      onClose: onCloseSpy,\n      classes: { paper: {} }\n    }\n\n    // Mock the useQuery hook for shared folder data\n    const sharedParentPath = getSharedParentPath(entries[0]?.path || '')\n    if (sharedParentPath) {\n      const folderName = sharedParentPath.split('/').pop() || 'Bills'\n      useQuery.mockReturnValue({\n        fetchStatus: 'loaded',\n        data: [{ name: folderName }]\n      })\n    } else {\n      useQuery.mockReturnValue({\n        fetchStatus: 'loaded',\n        data: []\n      })\n    }\n\n    useSharingContext.mockReturnValue({\n      sharedPaths,\n      refresh: refreshSpy,\n      getSharedParentPath,\n      hasSharedParent: path =>\n        sharedPaths.filter(sharedPath => path.includes(sharedPath)).length > 0,\n      byDocId,\n      allLoaded,\n      ...sharingContext\n    })\n\n    CozyFile.getFullpath.mockImplementation(\n      (destinationFolder, name) => `/${destinationFolder}/${name}`\n    )\n\n    move.mockImplementation(id => {\n      if (id === 'bill_201902') {\n        return Promise.resolve({\n          deleted: 'other_bill_201902',\n          moved: { id }\n        })\n      } else {\n        return Promise.resolve({\n          deleted: null,\n          moved: { id }\n        })\n      }\n    })\n\n    return render(\n      <AppLike client={mockClient}>\n        <MoveModal {...props} currentFolder={currentFolder} />\n      </AppLike>\n    )\n  }\n\n  describe('MoveModal', () => {\n    it('should wait for shares to load before authorising moves', async () => {\n      await waitFor(async () => {\n        setup({ allLoaded: false })\n      })\n\n      const moveButton = await screen.findByRole('button', {\n        name: 'Move'\n      })\n      expect(moveButton).toBeDisabled()\n    })\n\n    it('should move entries to destination', async () => {\n      CozyFile.getFullpath.mockImplementation((destinationFolder, name) =>\n        Promise.resolve(\n          name === 'bill_201903.pdf' ? '/bills/bill_201903.pdf' : '/whatever'\n        )\n      )\n\n      setup()\n\n      const moveButton = await screen.findByText('Move')\n      fireEvent.click(moveButton)\n\n      await waitFor(() => {\n        expect(move).toHaveBeenNthCalledWith(\n          1,\n          mockClient,\n          defaultEntries[0],\n          destinationFolder,\n          { force: true }\n        )\n\n        expect(move).toHaveBeenNthCalledWith(\n          2,\n          mockClient,\n          defaultEntries[1],\n          destinationFolder,\n          { force: true }\n        )\n        // don't force a shared file\n        expect(move).toHaveBeenNthCalledWith(\n          3,\n          mockClient,\n          defaultEntries[2],\n          destinationFolder,\n          { force: true }\n        )\n        expect(onCloseSpy).toHaveBeenCalled()\n        expect(refreshSpy).toHaveBeenCalled()\n        // TODO: check that trashedFiles are passed to cancel button\n      })\n    })\n  })\n\n  describe('move outside shared folder', () => {\n    it('should display an alert when moving files outside a shared folder', async () => {\n      setup({\n        sharedPaths: ['/bills'],\n        getSharedParentPath: path =>\n          path.includes('/bills') ? '/bills' : null,\n        byDocId: {}\n      })\n\n      const moveButton = await screen.findByText('Move')\n      fireEvent.click(moveButton)\n\n      await waitFor(() => {\n        expect(\n          screen.getByText('Moving outside the bills folder')\n        ).toBeInTheDocument()\n      })\n    })\n\n    it('should move files when user confirms', async () => {\n      setup({\n        sharedPaths: ['/bills'],\n        getSharedParentPath: path =>\n          path.includes('/bills') ? '/bills' : null,\n        byDocId: {}\n      })\n\n      const moveButton = await screen.findByText('Move')\n      fireEvent.click(moveButton)\n\n      await waitFor(() => {\n        const confirmButton = screen.getByText('I understand')\n        fireEvent.click(confirmButton)\n      })\n\n      await waitFor(() => {\n        expect(move).toHaveBeenCalled()\n        expect(onCloseSpy).toHaveBeenCalled()\n        expect(refreshSpy).toHaveBeenCalled()\n      })\n    })\n  })\n\n  describe('move inside shared folder', () => {\n    it('should display an alert when moving files inside a shared folder', async () => {\n      setup({\n        sharedPaths: ['/Destination Folder'],\n        getSharedParentPath: path =>\n          path.includes('/Destination Folder') ? '/Destination Folder' : null,\n        byDocId: {}\n      })\n\n      const moveButton = await screen.findByText('Move')\n      fireEvent.click(moveButton)\n\n      const modalTitle = await screen.findByText('Move to a shared folder?')\n      expect(modalTitle).toBeInTheDocument()\n    })\n\n    it('should move files when user confirms', async () => {\n      setup({\n        sharedPaths: ['/Destination Folder'],\n        getSharedParentPath: path =>\n          path.includes('/Destination Folder') ? '/Destination Folder' : null,\n        byDocId: {}\n      })\n\n      const moveButton = await screen.findByText('Move')\n      fireEvent.click(moveButton)\n\n      const confirmButton = await screen.findByText('Ok')\n      fireEvent.click(confirmButton)\n\n      await waitFor(() => {\n        expect(move).toHaveBeenCalled()\n        expect(onCloseSpy).toHaveBeenCalled()\n        expect(refreshSpy).toHaveBeenCalled()\n      })\n    })\n  })\n\n  describe('move shared folder inside another', () => {\n    it('should display an alert when move shared folder inside another', async () => {\n      CozyFile.getFullpath.mockImplementation((destinationFolder, name) =>\n        Promise.resolve(`/${destinationFolder}/${name}`)\n      )\n\n      setup({\n        sharedPaths: ['/bills', '/Destination Folder'],\n        byDocId: {\n          bill_201903: {\n            permissions: [],\n            sharings: ['sharing-id-1']\n          }\n        },\n        getSharedParentPath: path =>\n          path.includes('/bills')\n            ? '/bills'\n            : path.includes('/Destination Folder')\n              ? '/Destination Folder'\n              : null\n      })\n\n      const moveButton = await screen.findByText('Move')\n      fireEvent.click(moveButton)\n\n      await waitFor(() => {\n        expect(screen.getByText('Cannot be moved')).toBeInTheDocument()\n      })\n    })\n\n    it('should move files after revoke all recipients when folder owner confirms', async () => {\n      CozyFile.getFullpath.mockImplementation((destinationFolder, name) =>\n        Promise.resolve(`/${destinationFolder}/${name}`)\n      )\n      const revokeAllSpy = jest.fn()\n      const revokeSelfSpy = jest.fn()\n\n      setup({\n        sharedPaths: ['/bills', '/Destination Folder'],\n        byDocId: {\n          bill_201903: {\n            permissions: [],\n            sharings: ['sharing-id-1']\n          }\n        },\n        getSharedParentPath: path =>\n          path.includes('/bills')\n            ? '/bills'\n            : path.includes('/Destination Folder')\n              ? '/Destination Folder'\n              : null,\n        sharingContext: {\n          isOwner: () => true,\n          revokeAllRecipients: revokeAllSpy,\n          revokeSelf: revokeSelfSpy\n        }\n      })\n\n      const moveButton = await screen.findByText('Move')\n      fireEvent.click(moveButton)\n\n      await waitFor(() => {\n        const confirmButton = screen.getByText('Stop sharing')\n        fireEvent.click(confirmButton)\n      })\n\n      await waitFor(() => {\n        expect(move).toHaveBeenCalled()\n        expect(revokeAllSpy).toHaveBeenCalled()\n        expect(revokeSelfSpy).not.toHaveBeenCalled()\n        expect(onCloseSpy).toHaveBeenCalled()\n        expect(refreshSpy).toHaveBeenCalled()\n      })\n    })\n\n    it('should move files after revoke self when user confirms', async () => {\n      CozyFile.getFullpath.mockImplementation((destinationFolder, name) =>\n        Promise.resolve(`/${destinationFolder}/${name}`)\n      )\n      const revokeAllSpy = jest.fn()\n      const revokeSelfSpy = jest.fn()\n\n      setup({\n        sharedPaths: ['/bills', '/Destination Folder'],\n        byDocId: {\n          bill_201903: {\n            permissions: [],\n            sharings: ['sharing-id-1']\n          }\n        },\n        getSharedParentPath: path =>\n          path.includes('/bills')\n            ? '/bills'\n            : path.includes('/Destination Folder')\n              ? '/Destination Folder'\n              : null,\n        sharingContext: {\n          isOwner: () => false,\n          revokeAllRecipients: revokeAllSpy,\n          revokeSelf: revokeSelfSpy\n        }\n      })\n\n      const moveButton = await screen.findByText('Move')\n      fireEvent.click(moveButton)\n\n      await waitFor(() => {\n        const confirmButton = screen.getByText('Stop sharing')\n        fireEvent.click(confirmButton)\n      })\n\n      await waitFor(() => {\n        expect(move).toHaveBeenCalled()\n        expect(revokeSelfSpy).toHaveBeenCalled()\n        expect(revokeAllSpy).not.toHaveBeenCalled()\n        expect(onCloseSpy).toHaveBeenCalled()\n        expect(refreshSpy).toHaveBeenCalled()\n      })\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/move/MoveOutsideSharedFolderModal.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { useQuery } from 'cozy-client'\nimport { useSharingContext } from 'cozy-sharing'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport { useI18n } from 'twake-i18n'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport { getEntriesTypeTranslated } from '@/lib/entries'\nimport { buildFolderByPathQuery } from '@/queries'\n\n/**\n * Alert the user when is trying to move a folder/file outside of a shared folder\n */\nconst MoveOutsideSharedFolderModal = ({\n  entries,\n  driveId,\n  onCancel,\n  onConfirm\n}) => {\n  const { t } = useI18n()\n  const { getSharedParentPath } = useSharingContext()\n\n  const sharedParentPath = getSharedParentPath(entries[0]?.path || '')\n  const folderByPathQuery = buildFolderByPathQuery(sharedParentPath)\n  const { fetchStatus, data } = useQuery(\n    folderByPathQuery.definition,\n    folderByPathQuery.options\n  )\n\n  if (fetchStatus === 'loaded') {\n    const type = getEntriesTypeTranslated(t, entries)\n\n    const sharedFolderName = !driveId\n      ? data[0]?.name\n      : (entries[0]?.path?.split('/')?.[2] ?? '')\n\n    return (\n      <ConfirmDialog\n        open\n        title={t('Move.outsideSharedFolder.title', {\n          sharedFolder: sharedFolderName\n        })}\n        content={\n          <>\n            <Typography variant=\"body1\" className=\"u-mb-half\">\n              {t('Move.outsideSharedFolder.content_1', {\n                sharedFolder: sharedFolderName,\n                name: entries[0]?.name,\n                type,\n                smart_count: entries.length\n              })}\n            </Typography>\n            <Typography variant=\"body1\">\n              {t('Move.outsideSharedFolder.content_2', {\n                name: entries[0]?.name,\n                type,\n                smart_count: entries.length\n              })}\n            </Typography>\n          </>\n        }\n        actions={\n          <>\n            <Buttons\n              variant=\"secondary\"\n              label={t('Move.outsideSharedFolder.cancel')}\n              onClick={onCancel}\n            />\n            <Buttons\n              label={t('Move.outsideSharedFolder.confirm')}\n              onClick={onConfirm}\n            />\n          </>\n        }\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nMoveOutsideSharedFolderModal.propTypes = {\n  /** List of files or folder to move */\n  entries: PropTypes.array.isRequired,\n  /** Function called when the user cancels the move action */\n  onCancel: PropTypes.func.isRequired,\n  /** Function called when the user confirms the move action */\n  onConfirm: PropTypes.func.isRequired\n}\n\nexport { MoveOutsideSharedFolderModal }\n"
  },
  {
    "path": "src/modules/move/MoveSharedFolderInsideAnotherModal.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { useQuery } from 'cozy-client'\nimport { useSharingContext } from 'cozy-sharing'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport { useI18n } from 'twake-i18n'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport { getEntriesName } from '@/modules/move/helpers'\nimport {\n  buildFileOrFolderByIdQuery,\n  buildSharedDriveFileOrFolderByIdQuery\n} from '@/queries'\n\n/**\n * Alert the user when is trying to move a shared folder/file inside another shared folder\n */\nconst MoveSharedFolderInsideAnotherModal = ({\n  entries,\n  folderId,\n  driveId,\n  onCancel,\n  onConfirm\n}) => {\n  const { t } = useI18n()\n  const { byDocId } = useSharingContext()\n  const folderQuery = driveId\n    ? buildSharedDriveFileOrFolderByIdQuery({ fileId: folderId, driveId })\n    : buildFileOrFolderByIdQuery(folderId)\n  const { fetchStatus, data } = useQuery(\n    folderQuery.definition,\n    folderQuery.options\n  )\n\n  if (fetchStatus === 'loaded') {\n    const sharedEntries = entries.filter(\n      ({ _id }) => byDocId[_id] !== undefined\n    )\n\n    return (\n      <ConfirmDialog\n        open\n        title={t('Move.sharedFolderInsideAnother.title')}\n        content={\n          <>\n            <Typography variant=\"body1\" className=\"u-mb-half\">\n              {t('Move.sharedFolderInsideAnother.content_1')}\n            </Typography>\n            <Typography variant=\"body1\" className=\"u-mb-half\">\n              {t('Move.sharedFolderInsideAnother.content_2', {\n                source: getEntriesName(entries, t),\n                destination: data.name\n              })}\n            </Typography>\n            <ul>\n              {sharedEntries.map(({ _id, name }) => (\n                <li key={_id}>{name} </li>\n              ))}\n            </ul>\n          </>\n        }\n        actions={\n          <>\n            <Buttons\n              variant=\"secondary\"\n              label={t('Move.sharedFolderInsideAnother.cancel')}\n              onClick={onCancel}\n            />\n            <Buttons\n              label={t('Move.sharedFolderInsideAnother.confirm')}\n              onClick={onConfirm}\n            />\n          </>\n        }\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nMoveSharedFolderInsideAnotherModal.propTypes = {\n  /** List of files or folder to move */\n  entries: PropTypes.array.isRequired,\n  /** Id of the destination folder */\n  folderId: PropTypes.string.isRequired,\n  /** Function called when the user cancels the move action */\n  onCancel: PropTypes.func.isRequired,\n  /** Function called when the user confirms the move action */\n  onConfirm: PropTypes.func.isRequired\n}\n\nexport { MoveSharedFolderInsideAnotherModal }\n"
  },
  {
    "path": "src/modules/move/components/MoveModalSuccessAction.tsx",
    "content": "import React, { useState } from 'react'\nimport { NavigateFunction } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { OpenFolderButton } from '@/components/Button/OpenFolderButton'\nimport { File, FolderPickerEntry } from '@/components/FolderPicker/types'\nimport { cancelMove } from '@/modules/move/helpers'\nimport { useCancelable } from '@/modules/move/hooks/useCancelable'\n\ninterface MoveModalSuccessActionProps {\n  folder: File\n  entries: FolderPickerEntry[]\n  trashedFiles: File[]\n  canCancel?: boolean\n  refreshSharing: () => void\n  navigate: NavigateFunction\n}\n\nconst MoveModalSuccessAction: React.FC<MoveModalSuccessActionProps> = ({\n  folder,\n  entries,\n  trashedFiles,\n  canCancel = true,\n  refreshSharing,\n  navigate\n}) => {\n  const { t } = useI18n()\n  const client = useClient()\n  const { registerCancelable } = useCancelable()\n  const [isCancelling, setCancelling] = useState(false)\n  const { showAlert } = useAlert()\n\n  const handleCancel = async (): Promise<void> => {\n    setCancelling(true)\n    await cancelMove({\n      entries,\n      trashedFiles,\n      client,\n      registerCancelable,\n      showAlert,\n      t,\n      refreshSharing\n    })\n  }\n\n  return (\n    <>\n      {canCancel ? (\n        <Button\n          label={t('Move.cancel')}\n          onClick={handleCancel}\n          size=\"small\"\n          variant=\"text\"\n          disabled={isCancelling}\n          style={{ color: `var(--successContrastTextColor)` }}\n        />\n      ) : null}\n      <OpenFolderButton folder={folder} navigate={navigate} />\n    </>\n  )\n}\n\nexport { MoveModalSuccessAction }\n"
  },
  {
    "path": "src/modules/move/helpers.js",
    "content": "import logger from '@/lib/logger'\nimport { CozyFile } from '@/models'\n\n/**\n * Cancel file movement function\n * @param {object} client - The CozyClient instance\n * @param {import('cozy-client/types').IOCozyFile[]} entries - List of files moved\n * @param {import('cozy-client/types').IOCozyFile[]} trashedFiles - List of ids for files moved to the trash\n * @param {Function} registerCancelable - Function to register the promise\n * @param {Functione} refreshSharing - Function refresh sharing state\n */\nexport const cancelMove = async ({\n  client,\n  entries,\n  trashedFiles,\n  registerCancelable,\n  showAlert,\n  t,\n  refreshSharing\n}) => {\n  try {\n    await Promise.all(\n      entries.map(entry =>\n        registerCancelable(CozyFile.move(entry._id, { folderId: entry.dir_id }))\n      )\n    )\n    const fileCollection = client.collection(CozyFile.doctype)\n    let restoreErrorsCount = 0\n    await Promise.all(\n      trashedFiles.map(id => {\n        try {\n          registerCancelable(fileCollection.restore(id))\n        } catch {\n          restoreErrorsCount++\n        }\n      })\n    )\n    if (restoreErrorsCount) {\n      showAlert({\n        message: t('Move.cancelledWithRestoreErrors', {\n          subject: entries.length === 1 ? entries[0].name : '',\n          smart_count: entries.length,\n          restoreErrorsCount\n        }),\n        severity: 'secondary'\n      })\n    } else {\n      showAlert({\n        message: t('Move.cancelled', {\n          subject: entries.length === 1 ? entries[0].name : '',\n          smart_count: entries.length\n        }),\n        severity: 'secondary'\n      })\n    }\n  } catch (e) {\n    logger.warn(e)\n    showAlert({\n      message: t('Move.cancelled_error', { smart_count: entries.length }),\n      severity: 'error'\n    })\n  } finally {\n    if (refreshSharing) refreshSharing()\n  }\n}\n\n/**\n * Gets a name for the entry if there is only one, or a sentence with the number of elements if there are several\n * @param {import('cozy-client/types').IOCozyFile[]} entries - List of files moved\n * @param {Function} t - Translation function\n * @returns {string} - Name for entries\n */\nexport const getEntriesName = (entries, t) => {\n  return entries.length !== 1\n    ? t('Move.multipleEntries', {\n        smart_count: entries.length\n      })\n    : entries[0].name\n}\n\n/**\n * @typedef {Object} SharedDoc\n * @property {string[]} permissions - List of permissions\n * @property {string[]} sharings - List of sharings\n */\n\n/**\n * Returns whether one of the entries that is shared not only by link\n * @param {import('cozy-client/types').IOCozyFile[]} entries - List of files moved\n * @param {Object<string, SharedDoc>} byDocId - Object with shared files by id from cozy-sharing\n * @returns {boolean} - Whether one of the entries that is shared not only by link\n */\nexport const hasOneOfEntriesShared = (entries, byDocId) => {\n  const sharedEntries = entries.filter(({ _id }) => {\n    const doc = byDocId[_id]\n    if (doc === undefined) return false\n\n    const onlySharedByLink =\n      doc.permissions.length > 0 && doc.sharings.length === 0\n\n    if (onlySharedByLink) return false\n\n    return true\n  })\n  return sharedEntries.length > 0\n}\n"
  },
  {
    "path": "src/modules/move/helpers.spec.js",
    "content": "import CozyClient from 'cozy-client'\n\nimport { CozyFile } from '@/models'\nimport { cancelMove, hasOneOfEntriesShared } from '@/modules/move/helpers'\n\njest.mock('cozy-doctypes')\njest.mock('cozy-stack-client')\n\nCozyFile.doctype = 'io.cozy.files'\n\nconst getSpy = jest.fn().mockResolvedValue({\n  data: { id: 'fakeDoc', _type: 'io.cozy.files' }\n})\nconst refreshSpy = jest.fn()\nconst restoreSpy = jest.fn()\nconst collectionSpy = jest.fn(() => ({\n  get: getSpy,\n  restore: restoreSpy\n}))\nconst mockClient = new CozyClient({\n  stackClient: {\n    collection: collectionSpy,\n    on: jest.fn()\n  }\n})\nconst t = x => x\nconst showAlert = jest.fn()\n\ndescribe('cancelMove', () => {\n  const defaultEntries = [\n    {\n      _id: 'bill_201901',\n      dir_id: 'bills',\n      name: 'bill_201901.pdf'\n    },\n    {\n      _id: 'bill_201902',\n      dir_id: 'bills',\n      name: 'bill_201902.pdf'\n    },\n    // shared file:\n    {\n      _id: 'bill_201903',\n      dir_id: 'bills',\n      name: 'bill_201903.pdf'\n    }\n  ]\n  const setup = async ({\n    entries = defaultEntries,\n    trashedFiles = []\n  } = {}) => {\n    return cancelMove({\n      client: mockClient,\n      entries: entries,\n      trashedFiles: trashedFiles,\n      showAlert,\n      t,\n      registerCancelable: promise => promise,\n      refreshSharing: refreshSpy\n    })\n  }\n\n  it('should move items back to their previous location', async () => {\n    await setup()\n\n    expect(CozyFile.move).toHaveBeenCalledWith('bill_201901', {\n      folderId: 'bills'\n    })\n    expect(CozyFile.move).toHaveBeenCalledWith('bill_201902', {\n      folderId: 'bills'\n    })\n    expect(restoreSpy).not.toHaveBeenCalled()\n    expect(refreshSpy).toHaveBeenCalled()\n  })\n\n  it('should restore files that have been trashed due to conflicts', async () => {\n    await setup({\n      entries: [],\n      trashedFiles: ['trashed-1', 'trashed-2']\n    })\n\n    expect(collectionSpy).toHaveBeenCalledWith('io.cozy.files', {})\n    expect(restoreSpy).toHaveBeenCalledWith('trashed-1')\n    expect(restoreSpy).toHaveBeenCalledWith('trashed-2')\n    expect(refreshSpy).toHaveBeenCalled()\n  })\n})\n\ndescribe('hasOneOfEntriesShared', () => {\n  it('should return false if entries are not shared', () => {\n    const entries = [{ _id: '1' }, { _id: '2' }, { _id: '3' }]\n    const byDocId = {}\n    expect(hasOneOfEntriesShared(entries, byDocId)).toBe(false)\n  })\n\n  it('should return false if all entries are only shared by link', () => {\n    const entries = [{ _id: '1' }, { _id: '2' }, { _id: '3' }]\n    const byDocId = {\n      1: { permissions: ['permission1'], sharings: [] },\n      2: { permissions: ['permission2'], sharings: [] }\n    }\n    expect(hasOneOfEntriesShared(entries, byDocId)).toBe(false)\n  })\n\n  it('should return true if at least one entry is shared', () => {\n    const entries = [{ _id: '1' }, { _id: '2' }, { _id: '3' }]\n    const byDocId = {\n      2: { permissions: [], sharings: ['sharingId'] }\n    }\n    expect(hasOneOfEntriesShared(entries, byDocId)).toBe(true)\n  })\n})\n"
  },
  {
    "path": "src/modules/move/hooks/useCancelable.jsx",
    "content": "import { useEffect, useRef, useCallback } from 'react'\n\nimport { cancelable } from 'cozy-client'\n\nconst useCancelable = () => {\n  const promisesRef = useRef([])\n\n  useEffect(() => {\n    // Cleanup function to cancel all promises\n    return () => {\n      promisesRef.current.forEach(p => p.cancel())\n      promisesRef.current = []\n    }\n  }, [])\n\n  const registerCancelable = useCallback(promise => {\n    const cancelableP = cancelable(promise)\n    promisesRef.current.push(cancelableP)\n    return cancelableP\n  }, [])\n\n  return {\n    registerCancelable\n  }\n}\n\nexport { useCancelable }\n"
  },
  {
    "path": "src/modules/move/hooks/useMove.tsx",
    "content": "import React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { File, FolderPickerEntry } from '@/components/FolderPicker/types'\nimport { MoveModalSuccessAction } from '@/modules/move/components/MoveModalSuccessAction'\n\ninterface useMoveProps {\n  entries: FolderPickerEntry[]\n}\n\ninterface showSuccessProps {\n  folder: File\n  trashedFiles: File[]\n  canCancel?: boolean\n  refreshSharing: () => void\n}\n\ninterface useMoveReturn {\n  showSuccess: (props: showSuccessProps) => void\n}\n\nconst useMove = ({ entries }: useMoveProps): useMoveReturn => {\n  const { t } = useI18n()\n  const navigate = useNavigate()\n  const { showAlert } = useAlert()\n\n  const showSuccess = ({\n    folder,\n    trashedFiles,\n    canCancel = true,\n    refreshSharing\n  }: showSuccessProps): void => {\n    const targetName = folder.name || t('breadcrumb.title_drive')\n\n    showAlert({\n      message: t('Move.success', {\n        smart_count: entries.length,\n        subject: entries.length === 1 ? entries[0].name : '',\n        target: targetName\n      }),\n      severity: 'success',\n      action: (\n        <MoveModalSuccessAction\n          folder={folder}\n          entries={entries}\n          trashedFiles={trashedFiles}\n          canCancel={canCancel}\n          refreshSharing={refreshSharing}\n          navigate={navigate}\n        />\n      )\n    })\n  }\n\n  return { showSuccess }\n}\n\nexport { useMove }\n"
  },
  {
    "path": "src/modules/navigation/AppRoute.jsx",
    "content": "import React from 'react'\nimport { Route, useParams, Outlet, Navigate } from 'react-router-dom'\n\nimport { BarRoutes } from 'cozy-bar'\nimport flag from 'cozy-flags'\n\nimport ExternalRedirect from './ExternalRedirect'\nimport Index from './Index'\nimport AIAssistantPaywallView from '../views/AI/AIAssistantPaywallView'\nimport { DriveFolderView } from '../views/Drive/DriveFolderView'\nimport FilesViewerDrive from '../views/Drive/FilesViewerDrive'\nimport OnlyOfficeView from '../views/OnlyOffice'\nimport OnlyOfficeCreateView from '../views/OnlyOffice/Create'\nimport OnlyOfficePaywallView from '../views/OnlyOffice/OnlyOfficePaywallView'\nimport RecentView from '../views/Recent'\nimport FilesViewerRecent from '../views/Recent/FilesViewerRecent'\nimport FilesViewerSharedDrive from '../views/SharedDrive/FilesViewerSharedDrive'\nimport SharingsView from '../views/Sharings'\nimport SharingsFilesViewer from '../views/Sharings/FilesViewerSharings'\nimport SharingsFolderView from '../views/Sharings/SharingsFolderView'\nimport FilesViewerTrash from '../views/Trash/FilesViewerTrash'\nimport TrashFolderView from '../views/Trash/TrashFolderView'\n\nimport FileHistory from '@/components/FileHistory'\nimport {\n  ROOT_DIR_ID,\n  TRASH_DIR_ID,\n  SHARED_DRIVES_DIR_ID,\n  SHARING_TAB_DRIVES\n} from '@/constants/config'\nimport { SentryRoutes } from '@/lib/sentry'\nimport { UploaderComponent } from '@/modules//views/Upload/UploaderComponent'\nimport Layout from '@/modules/layout/Layout'\nimport { PublicNoteRedirect } from '@/modules/navigation/PublicNoteRedirect'\nimport FileOpenerExternal from '@/modules/viewer/FileOpenerExternal'\nimport { KonnectorRoutes } from '@/modules/views/Drive/KonnectorRoutes'\nimport { FavoritesView } from '@/modules/views/Favorites/FavoritesView'\nimport { FolderDuplicateView } from '@/modules/views/Folder/FolderDuplicateView'\nimport { DuplicateSharedDriveFilesView } from '@/modules/views/Modal/DuplicateSharedDriveFilesView'\nimport { MoveFilesView } from '@/modules/views/Modal/MoveFilesView'\nimport { MoveSharedDriveFilesView } from '@/modules/views/Modal/MoveSharedDriveFilesView'\nimport { QualifyFileView } from '@/modules/views/Modal/QualifyFileView'\nimport { ShareDisplayedFolderView } from '@/modules/views/Modal/ShareDisplayedFolderView'\nimport { ShareFileView } from '@/modules/views/Modal/ShareFileView'\nimport { NextcloudDeleteView } from '@/modules/views/Nextcloud/NextcloudDeleteView'\nimport { NextcloudDestroyView } from '@/modules/views/Nextcloud/NextcloudDestroyView'\nimport { NextcloudDuplicateView } from '@/modules/views/Nextcloud/NextcloudDuplicateView'\nimport { NextcloudFolderView } from '@/modules/views/Nextcloud/NextcloudFolderView'\nimport { NextcloudMoveView } from '@/modules/views/Nextcloud/NextcloudMoveView'\nimport { NextcloudTrashEmptyView } from '@/modules/views/Nextcloud/NextcloudTrashEmptyView'\nimport { NextcloudTrashView } from '@/modules/views/Nextcloud/NextcloudTrashView'\nimport SearchView from '@/modules/views/Search/SearchView'\nimport { SharedDriveFolderView } from '@/modules/views/SharedDrive/SharedDriveFolderView'\nimport { TrashDestroyView } from '@/modules/views/Trash/TrashDestroyView'\nimport { TrashEmptyView } from '@/modules/views/Trash/TrashEmptyView'\n\nconst FilesRedirect = () => {\n  const { folderId } = useParams()\n  return <Navigate to={`/folder/${folderId}`} replace={true} />\n}\n\nconst SharedDrivesRedirect = () => {\n  return <Navigate to={`/sharings?tab=${SHARING_TAB_DRIVES}`} replace={true} />\n}\n\nconst OutletWrapper = ({ Component }) => (\n  <>\n    <Component />\n    <Outlet />\n  </>\n)\n\nconst AppRoute = () => (\n  <SentryRoutes>\n    <Route path=\"external/:fileId\" element={<ExternalRedirect />} />\n    <Route path=\"note/:fileId\" element={<PublicNoteRedirect />} />\n    <Route path=\"note/:driveId/:fileId\" element={<PublicNoteRedirect />} />\n\n    <Route element={<Layout />}>\n      <Route path=\"upload\" element={<UploaderComponent />} />\n      <Route path=\"/files/:folderId\" element={<FilesRedirect />} />\n      <Route path=\"/\" element={<Index />} />\n\n      <Route\n        path=\"folder\"\n        element={<Navigate to={ROOT_DIR_ID} replace={true} />}\n      />\n      <Route path=\"folder/:folderId\" element={<DriveFolderView />}>\n        <Route\n          path=\"file/:fileId\"\n          element={<OutletWrapper Component={FilesViewerDrive} />}\n        >\n          <Route path=\"v/revision\" element={<FileHistory />} />\n          <Route path=\"v/share\" element={<ShareFileView />} />\n          <Route path=\"v/move\" element={<MoveFilesView isOpenInViewer />} />\n          <Route path=\"v/duplicate\" element={<FolderDuplicateView />} />\n          <Route path=\"v/ai/paywall\" element={<AIAssistantPaywallView />} />\n        </Route>\n        <Route path=\"file/:fileId/revision\" element={<FileHistory />} />\n        <Route path=\"file/:fileId/share\" element={<ShareFileView />} />\n        <Route path=\"file/:fileId/qualify\" element={<QualifyFileView />} />\n        <Route path=\"paywall\" element={<OnlyOfficePaywallView />} />\n        <Route path=\"share\" element={<ShareDisplayedFolderView />} />\n        <Route path=\"move\" element={<MoveFilesView />} />\n        <Route path=\"harvest/:konnectorSlug/*\" element={<KonnectorRoutes />} />\n        <Route path=\"duplicate\" element={<FolderDuplicateView />} />\n      </Route>\n\n      <Route\n        path={`folder/${SHARED_DRIVES_DIR_ID}`}\n        element={<SharedDrivesRedirect />}\n      >\n        <Route path=\"file/:fileId\" element={<FilesViewerDrive />} />\n      </Route>\n\n      {!flag('drive.hide-nextcloud-dev') ? (\n        <>\n          <Route\n            path=\"nextcloud/:sourceAccount\"\n            element={<NextcloudFolderView />}\n          >\n            <Route path=\"move\" element={<NextcloudMoveView />} />\n            <Route path=\"delete\" element={<NextcloudDeleteView />} />\n            <Route path=\"duplicate\" element={<NextcloudDuplicateView />} />\n          </Route>\n          <Route\n            path=\"nextcloud/:sourceAccount/trash\"\n            element={<NextcloudTrashView />}\n          >\n            <Route path=\"empty\" element={<NextcloudTrashEmptyView />} />\n            <Route path=\"destroy\" element={<NextcloudDestroyView />} />\n          </Route>\n        </>\n      ) : null}\n\n      {flag('drive.shared-drive.enabled') ||\n      flag('drive.federated-shared-folder.enabled') ? (\n        <>\n          <Route\n            path=\"shareddrive/:driveId/:folderId\"\n            element={<SharedDriveFolderView />}\n          >\n            <Route\n              path=\"file/:fileId\"\n              element={<OutletWrapper Component={FilesViewerSharedDrive} />}\n            />\n            <Route path=\"file/:fileId/revision\" element={<FileHistory />} />\n            <Route path=\"file/:fileId/v/revision\" element={<FileHistory />} />\n            <Route path=\"file/:fileId/share\" element={<ShareFileView />} />\n            <Route path=\"share\" element={<ShareDisplayedFolderView />} />\n            <Route path=\"move\" element={<MoveSharedDriveFilesView />} />\n            <Route\n              path=\"duplicate\"\n              element={<DuplicateSharedDriveFilesView />}\n            />\n          </Route>\n        </>\n      ) : null}\n\n      <Route path=\"recent\" element={<RecentView />}>\n        <Route\n          path=\"file/:fileId\"\n          element={<OutletWrapper Component={FilesViewerRecent} />}\n        >\n          <Route path=\"v/revision\" element={<FileHistory />} />\n          <Route path=\"v/share\" element={<ShareFileView />} />\n          <Route path=\"v/move\" element={<MoveFilesView isOpenInViewer />} />\n          <Route path=\"v/duplicate\" element={<FolderDuplicateView />} />\n        </Route>\n        <Route path=\"file/:fileId/revision\" element={<FileHistory />} />\n        <Route path=\"file/:fileId/share\" element={<ShareFileView />} />\n        <Route path=\"file/:fileId/qualify\" element={<QualifyFileView />} />\n        <Route path=\"share\" element={<ShareDisplayedFolderView />} />\n        <Route path=\"move\" element={<MoveFilesView />} />\n      </Route>\n\n      <Route\n        path=\"trash\"\n        element={<Navigate to={TRASH_DIR_ID} replace={true} />}\n      />\n\n      <Route path=\"trash/:folderId\" element={<TrashFolderView />}>\n        <Route path=\"file/:fileId\" element={<FilesViewerTrash />} />\n        <Route path=\"file/:fileId/revision\" element={<FileHistory />} />\n        <Route path=\"empty\" element={<TrashEmptyView />} />\n        <Route path=\"destroy\" element={<TrashDestroyView />} />\n      </Route>\n\n      <Route path=\"sharings\">\n        <Route index element={<SharingsView />} />\n        <Route element={<SharingsView />}>\n          <Route\n            path=\"file/:fileId\"\n            element={<OutletWrapper Component={SharingsFilesViewer} />}\n          >\n            <Route path=\"v/revision\" element={<FileHistory />} />\n            <Route path=\"v/share\" element={<ShareFileView />} />\n            <Route path=\"v/move\" element={<MoveFilesView isOpenInViewer />} />\n            <Route path=\"v/duplicate\" element={<FolderDuplicateView />} />\n          </Route>\n          {/* This route must be a child of SharingsView so the modal opens on top of the sharing view */}\n          <Route path=\"file/:fileId/revision\" element={<FileHistory />} />\n          <Route path=\"file/:fileId/share\" element={<ShareFileView />} />\n          <Route path=\"file/:fileId/qualify\" element={<QualifyFileView />} />\n        </Route>\n        {/* This route must be inside the /sharing path for the nav to have an activate state */}\n        <Route path=\":folderId\" element={<SharingsFolderView />}>\n          <Route path=\"file/:fileId\" element={<SharingsFilesViewer />} />\n          {/* This route must be a child of SharingsFolderView so the modal opens on top of the folder view */}\n          <Route path=\"file/:fileId/revision\" element={<FileHistory />} />\n          <Route path=\"file/:fileId/share\" element={<ShareFileView />} />\n          <Route path=\"file/:fileId/qualify\" element={<QualifyFileView />} />\n          <Route path=\"share\" element={<ShareDisplayedFolderView />} />\n        </Route>\n        <Route path=\"move\" element={<MoveFilesView />} />\n      </Route>\n\n      <Route path=\"onlyoffice/:fileId\" element={<OnlyOfficeView />}>\n        <Route path=\"paywall\" element={<OnlyOfficePaywallView />} />\n      </Route>\n      <Route path=\"onlyoffice/:driveId/:fileId\" element={<OnlyOfficeView />}>\n        <Route path=\"paywall\" element={<OnlyOfficePaywallView />} />\n      </Route>\n\n      <Route\n        path=\"onlyoffice/create/:folderId/:fileClass\"\n        element={<OnlyOfficeCreateView />}\n      />\n\n      <Route\n        path=\"onlyoffice/create/:driveId/:folderId/:fileClass\"\n        element={<OnlyOfficeCreateView />}\n      />\n\n      <Route path=\"file/:fileId\" element={<FileOpenerExternal />} />\n\n      <Route path=\"search\" element={<SearchView />} />\n\n      <Route path=\"favorites\" element={<FavoritesView />}>\n        <Route path=\"file/:fileId/revision\" element={<FileHistory />} />\n        <Route path=\"file/:fileId/share\" element={<ShareFileView />} />\n        <Route path=\"file/:fileId/qualify\" element={<QualifyFileView />} />\n        <Route path=\"share\" element={<ShareDisplayedFolderView />} />\n        <Route path=\"move\" element={<MoveFilesView />} />\n      </Route>\n\n      {BarRoutes.map(BarRoute => BarRoute)}\n    </Route>\n  </SentryRoutes>\n)\n\nexport default AppRoute\n"
  },
  {
    "path": "src/modules/navigation/ExternalNavItem.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React, { useCallback } from 'react'\n\nimport { useClient, generateWebLink } from 'cozy-client'\nimport { isFlagshipApp } from 'cozy-device-helper'\nimport { useWebviewIntent } from 'cozy-intent'\nimport {\n  NavLink as UINavLink,\n  NavItem as UINavItem\n} from 'cozy-ui/transpiled/react/Nav'\nimport { useI18n } from 'twake-i18n'\n\nimport { NavContent } from '@/modules/navigation/NavContent'\n\nconst ExternalNavItem = ({ slug, icon, label, path, clickState }) => {\n  const { t } = useI18n()\n  const client = useClient()\n  const webviewIntent = useWebviewIntent()\n\n  const href = generateWebLink({\n    slug,\n    cozyUrl: client.getStackClient().uri,\n    subDomainType: client.getInstanceOptions().subdomain,\n    ...(path && { hash: path })\n  })\n\n  const handleClick = useCallback(\n    e => {\n      e.preventDefault()\n      if (clickState) {\n        clickState[1](undefined)\n      }\n      if (isFlagshipApp()) {\n        webviewIntent.call('openApp', href, { slug })\n      } else {\n        window.location.href = href\n      }\n    },\n    [href, slug, webviewIntent, clickState]\n  )\n\n  return (\n    <UINavItem>\n      <a href={href} onClick={handleClick} className={UINavLink.className}>\n        <NavContent icon={icon} label={t(`Nav.item_${label}`)} />\n      </a>\n    </UINavItem>\n  )\n}\n\nExternalNavItem.propTypes = {\n  slug: PropTypes.string.isRequired,\n  icon: PropTypes.element.isRequired,\n  label: PropTypes.string.isRequired,\n  path: PropTypes.string,\n  clickState: PropTypes.array\n}\n\nexport { ExternalNavItem }\n"
  },
  {
    "path": "src/modules/navigation/ExternalRedirect.jsx",
    "content": "import React from 'react'\nimport { useParams } from 'react-router-dom'\n\nimport { useClient, useFetchShortcut } from 'cozy-client'\nimport Empty from 'cozy-ui/transpiled/react/Empty'\nimport GlobeIcon from 'cozy-ui/transpiled/react/Icons/Globe'\nimport { translate } from 'twake-i18n'\n\nimport EmptyIcon from '@/assets/icons/icon-folder-broken.svg'\nimport { DummyLayout } from '@/modules/layout/DummyLayout'\n\nconst ExternalRedirect = ({ t }) => {\n  const { fileId } = useParams()\n  const client = useClient()\n  const { shortcutInfos, fetchStatus } = useFetchShortcut(client, fileId)\n  if (shortcutInfos) {\n    // eslint-disable-next-line react-hooks/immutability\n    window.location.href = shortcutInfos.data.attributes.url\n  }\n\n  return (\n    <DummyLayout>\n      {fetchStatus === 'failed' && (\n        <Empty\n          data-testid=\"empty-share\"\n          icon={EmptyIcon}\n          title={t('External.redirection.title')}\n          text={t('External.redirection.error')}\n        />\n      )}\n      {fetchStatus !== 'failed' && (\n        <Empty\n          data-testid=\"empty-share\"\n          icon={GlobeIcon}\n          title={t('External.redirection.title')}\n          text={t('External.redirection.text')}\n        />\n      )}\n    </DummyLayout>\n  )\n}\n\nexport default translate()(ExternalRedirect)\n"
  },
  {
    "path": "src/modules/navigation/FavoriteList.tsx",
    "content": "import React, { FC } from 'react'\n\nimport { useQuery } from 'cozy-client'\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport { NavDesktopDropdown } from 'cozy-ui/transpiled/react/Nav'\nimport { useI18n } from 'twake-i18n'\n\nimport { FavoriteListItem } from '@/modules/navigation/FavoriteListItem'\nimport { buildFavoritesQuery } from '@/queries'\n\ninterface FavoriteListProps {\n  clickState: [string, (value: string | undefined) => void]\n}\n\nconst FavoriteList: FC<FavoriteListProps> = ({ clickState }) => {\n  const { t } = useI18n()\n  const favoritesQuery = buildFavoritesQuery({\n    sortAttribute: 'name',\n    sortOrder: 'desc'\n  })\n  const favoritesResult = useQuery(\n    favoritesQuery.definition,\n    favoritesQuery.options\n  ) as {\n    data?: IOCozyFile[] | null\n  }\n\n  if (favoritesResult.data && favoritesResult.data.length > 0) {\n    return (\n      <NavDesktopDropdown label={t('Nav.item_favorites')}>\n        {favoritesResult.data.map(file => (\n          <FavoriteListItem\n            key={file._id}\n            file={file}\n            clickState={clickState}\n          />\n        ))}\n      </NavDesktopDropdown>\n    )\n  }\n\n  return null\n}\n\nexport { FavoriteList }\n"
  },
  {
    "path": "src/modules/navigation/FavoriteListItem.tsx",
    "content": "import React, { FC } from 'react'\n\nimport {\n  splitFilename,\n  isDirectory,\n  isNote,\n  isOnlyOfficeFile\n} from 'cozy-client/dist/models/file'\nimport type { IOCozyFile } from 'cozy-client/types/types'\nimport FileIcon from 'cozy-ui/transpiled/react/Icons/File'\nimport FileTypeServerIcon from 'cozy-ui/transpiled/react/Icons/FileTypeServer'\nimport FolderIcon from 'cozy-ui/transpiled/react/Icons/Folder'\nimport { NavIcon, NavLink, NavItem } from 'cozy-ui/transpiled/react/Nav'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\n\nimport { FileLink } from './components/FileLink'\n\nimport { useFileLink } from '@/modules/navigation/hooks/useFileLink'\nimport { isNextcloudShortcut } from '@/modules/nextcloud/helpers'\n\ninterface FavoriteListItemProps {\n  file: IOCozyFile\n  clickState: [string, (value: string | undefined) => void]\n}\n\nconst makeIcon = (file: IOCozyFile): string | React.ComponentType =>\n  isNextcloudShortcut(file)\n    ? FileTypeServerIcon\n    : isDirectory(file)\n      ? FolderIcon\n      : FileIcon\n\nconst FavoriteListItem: FC<FavoriteListItemProps> = ({\n  file,\n  // eslint-disable-next-line @typescript-eslint/no-unused-vars\n  clickState: [lastClicked, setLastClicked]\n}) => {\n  const { link } = useFileLink(file, {\n    forceFolderPath: isNote(file) || isOnlyOfficeFile(file) ? false : true\n  })\n  const { filename } = splitFilename(file)\n\n  const ItemIcon = makeIcon(file)\n\n  return (\n    <NavItem key={file._id}>\n      <FileLink\n        link={link}\n        className={NavLink.className}\n        onClick={(): void => setLastClicked(undefined)}\n      >\n        <NavIcon icon={ItemIcon} />\n        <Typography\n          className=\"u-fz-small\"\n          variant=\"inherit\"\n          color=\"inherit\"\n          noWrap\n        >\n          {filename}\n        </Typography>\n      </FileLink>\n    </NavItem>\n  )\n}\n\nexport { FavoriteListItem }\n"
  },
  {
    "path": "src/modules/navigation/Index.jsx",
    "content": "import { useEffect, useContext } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useClient, Q, models } from 'cozy-client'\n\nimport { getSharingIdFromUrl } from './duck'\n\nimport { SHAREDWITHME_DIR_ID } from '@/constants/config'\nimport AcceptingSharingContext from '@/lib/AcceptingSharingContext'\n\n/**\n * Compute sharing object according to the sharing found in io.cozy.sharings and the sharing context\n * @param {object} params - Params\n * @param {object} params.client - The CozyClient instance\n * @param {string} params.sharingId - Id of an io.cozy.sharings doc\n * @param {object} params.sharingsValue - Sharing Context value\n * @returns {object}\n */\nconst computeSharingsValue = async ({ client, sharingId, sharingsValue }) => {\n  const sharingRes = await client.query(\n    Q('io.cozy.sharings').getById(sharingId)\n  )\n  const computedSharingsValue = Object.assign(\n    { [`${sharingRes.data.id}`]: sharingRes.data },\n    sharingsValue\n  )\n  return computedSharingsValue\n}\n\n/**\n * Fetches io.cozy.sharings with the sharing Id\n * stores the sharing in the context\n * and route to the folder that contains the shared file\n * @param {object} params - Params\n * @param {object} params.client - The CozyClient instance\n * @param {object} params.sharingsValue - Sharing Context value\n * @param {function} params.setSharingsValue - Sharing Context setter\n * @param {function} params.setFileValue - Sharing Context file setter\n * @param {object} params.navigate - Lets you navigate programmatically\n * @param {string} params.sharingId - Id of an io.cozy.sharings doc\n */\nexport const fetchSharing = async ({\n  client,\n  sharingsValue,\n  setSharingsValue,\n  setFileValue,\n  navigate,\n  sharingId\n}) => {\n  if (!sharingId) {\n    return navigate('/folder', { replace: true })\n  }\n\n  try {\n    const referencedFilesRes = await client\n      .collection('io.cozy.files')\n      .findReferencedBy({ _type: 'io.cozy.sharings', _id: sharingId })\n\n    const referencedFiles = referencedFilesRes.included\n\n    const hasReferencedFile = referencedFiles.length >= 1\n    const referencedFile = hasReferencedFile ? referencedFiles[0] : null\n\n    const isSharingShortcut = hasReferencedFile\n      ? models.file.isSharingShortcut(referencedFile)\n      : false\n\n    if (isSharingShortcut) {\n      setFileValue(referencedFile)\n    }\n\n    if (!hasReferencedFile || isSharingShortcut) {\n      const computedSharingsValue = await computeSharingsValue({\n        client,\n        sharingId,\n        sharingsValue\n      })\n      setSharingsValue(computedSharingsValue)\n    }\n\n    if (!hasReferencedFile) {\n      return navigate(`/folder/${SHAREDWITHME_DIR_ID}`, { replace: true })\n    }\n    return navigate(`/folder/${referencedFile.dir_id}`, { replace: true })\n  } catch (e) {\n    // eslint-disable-next-line\n    console.warn(\n      `fetchSharing error : ${e}. Redirect to /folder/${SHAREDWITHME_DIR_ID}`\n    )\n    return navigate(`/folder/${SHAREDWITHME_DIR_ID}`, { replace: true })\n  }\n}\n\nconst Index = () => {\n  const client = useClient()\n  const navigate = useNavigate()\n  const { sharingsValue, setFileValue, setSharingsValue } = useContext(\n    AcceptingSharingContext\n  )\n\n  const sharingId = getSharingIdFromUrl(window.location)\n\n  useEffect(() => {\n    fetchSharing({\n      client,\n      sharingsValue,\n      setSharingsValue,\n      navigate,\n      sharingId,\n      setFileValue\n    })\n  }, [\n    client,\n    sharingId,\n    navigate,\n    setSharingsValue,\n    setFileValue,\n    sharingsValue\n  ])\n\n  return null\n}\n\nexport default Index\n"
  },
  {
    "path": "src/modules/navigation/Index.spec.js",
    "content": "import { createMockClient } from 'cozy-client'\n\nimport { fetchSharing } from './Index'\n\nimport { SHAREDWITHME_DIR_ID } from '@/constants/config'\n\nconst mockFileModels = require('cozy-client/dist/models/file')\n\njest.mock('cozy-keys-lib', () => ({\n  withVaultClient: jest.fn().mockReturnValue({}),\n  useVaultClient: jest.fn()\n}))\nconst client = createMockClient({})\nconst navigate = jest.fn()\nconst setSharingsValue = jest.fn()\nconst setFileValue = jest.fn()\nconst sharingRes = { data: { id: '123' } }\nconst referencedFilesRes = { included: [{ id: 'fileId', dir_id: 'dirId' }] }\n\nconst setup = async ({ sharingId, withReferencedFiles, withShortcut } = {}) => {\n  client.query = jest\n    .fn()\n    .mockReturnValue(sharingId ? sharingRes : { data: [] })\n  mockFileModels.isSharingShortcut = () => (withShortcut ? true : false)\n  client.collection = jest.fn(() => ({\n    findReferencedBy: jest\n      .fn()\n      .mockReturnValue(\n        withReferencedFiles ? referencedFilesRes : { included: [] }\n      )\n  }))\n\n  await fetchSharing({\n    client,\n    navigate,\n    sharingsValue: {},\n    setSharingsValue: setSharingsValue,\n    setFileValue: setFileValue,\n    sharingId\n  })\n}\n\n/**\n * Here's how it works: if there is a sharing id, we are in the sharing process.\n * If there is a reference file, it means that a file in the current folder is linked to the current share.\n * So we can check if it's a shortcut and then store it in the context to use it in the view.\n * As for the redirection, it is done according to whether there is a reference file or not.\n */\ndescribe('fetchSharing', () => {\n  it('should redirect to /folder and store nothing in context, if no sharing id', async () => {\n    await setup()\n\n    expect(setSharingsValue).not.toHaveBeenCalled()\n    expect(setFileValue).not.toHaveBeenCalled()\n    expect(navigate).toHaveBeenCalledWith('/folder', { replace: true })\n  })\n\n  it('should redirect to /shared-with-me-dir and store sharing in context, if sharing id but no referenced file', async () => {\n    await setup({\n      sharingId: '123'\n    })\n\n    expect(setSharingsValue).toHaveBeenCalled()\n    expect(setFileValue).not.toHaveBeenCalled()\n    expect(navigate).toHaveBeenCalledWith(`/folder/${SHAREDWITHME_DIR_ID}`, {\n      replace: true\n    })\n  })\n\n  it('should redirect to /folder/dirId and store nothing in context, if sharing id and referenced file but no shortcut', async () => {\n    await setup({\n      sharingId: '123',\n      withReferencedFiles: true\n    })\n\n    expect(setSharingsValue).not.toHaveBeenCalled()\n    expect(setFileValue).not.toHaveBeenCalled()\n    expect(navigate).toHaveBeenCalledWith('/folder/dirId', { replace: true })\n  })\n\n  it('should redirect to /folder/dirId and store sharing and file in context, if sharing id, referenced file and shortcut', async () => {\n    await setup({\n      sharingId: '123',\n      withReferencedFiles: true,\n      withShortcut: true\n    })\n\n    expect(setSharingsValue).toHaveBeenCalled()\n    expect(setFileValue).toHaveBeenCalled()\n    expect(navigate).toHaveBeenCalledWith('/folder/dirId', { replace: true })\n  })\n})\n"
  },
  {
    "path": "src/modules/navigation/Nav.jsx",
    "content": "import React from 'react'\n\nimport flag from 'cozy-flags'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ClockIcon from 'cozy-ui/transpiled/react/Icons/ClockOutline'\nimport CloudIcon from 'cozy-ui/transpiled/react/Icons/Cloud2'\nimport StarIcon from 'cozy-ui/transpiled/react/Icons/Star'\nimport TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'\nimport { NavDesktopDropdown } from 'cozy-ui/transpiled/react/Nav'\nimport UINav from 'cozy-ui/transpiled/react/Nav'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport NextcloudIcon from '@/assets/icons/icon-nextcloud.svg'\nimport { ExternalNavItem } from '@/modules/navigation/ExternalNavItem'\nimport { FavoriteList } from '@/modules/navigation/FavoriteList'\nimport { useNavContext } from '@/modules/navigation/NavContext'\nimport { NavItem } from '@/modules/navigation/NavItem'\nimport { SharingsNavItem } from '@/modules/navigation/SharingsNavItem'\nimport { ExternalDrives } from '@/modules/navigation/components/ExternalDrivesList'\n\nexport const Nav = () => {\n  const clickState = useNavContext()\n  const { isDesktop } = useBreakpoints()\n  const { t } = useI18n()\n\n  return (\n    <UINav>\n      <NavItem\n        to=\"/folder\"\n        icon={<Icon icon={CloudIcon} />}\n        label=\"drive\"\n        rx={/\\/(folder|nextcloud)(\\/.*)?/}\n        clickState={clickState}\n      />\n      {!isDesktop ? (\n        <NavItem\n          to=\"/favorites\"\n          icon={<Icon icon={StarIcon} />}\n          label=\"favorites\"\n          rx={/\\/favorites(\\/.*)?/}\n          clickState={clickState}\n        />\n      ) : null}\n      <NavItem\n        to=\"/recent\"\n        icon={<Icon icon={ClockIcon} />}\n        label=\"recent\"\n        rx={/\\/recent(\\/.*)?/}\n        clickState={clickState}\n      />\n      <SharingsNavItem clickState={clickState} />\n      <NavItem\n        to=\"/trash\"\n        icon={<Icon icon={TrashIcon} />}\n        label=\"trash\"\n        rx={/\\/trash(\\/.*)?/}\n        clickState={clickState}\n      />\n      {flag('settings.migration.enabled') && (\n        <NavDesktopDropdown label={t('Nav.item_migration')} limit={0}>\n          <ExternalNavItem\n            slug=\"settings\"\n            icon={<Icon icon={NextcloudIcon} />}\n            label=\"nextcloud\"\n            path=\"/migration\"\n            clickState={clickState}\n          />\n        </NavDesktopDropdown>\n      )}\n      {isDesktop ? <FavoriteList clickState={clickState} /> : null}\n      {isDesktop ? (\n        <ExternalDrives clickState={clickState} className=\"u-mt-half\" />\n      ) : null}\n    </UINav>\n  )\n}\n\nexport default Nav\n"
  },
  {
    "path": "src/modules/navigation/NavContent.tsx",
    "content": "import React from 'react'\n\nimport Avatar from 'cozy-ui/transpiled/react/Avatar'\nimport Badge from 'cozy-ui/transpiled/react/Badge'\nimport { NavIcon, NavText } from 'cozy-ui/transpiled/react/Nav'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\ninterface NavContentProps {\n  icon?: string\n  badgeContent?: number\n  label?: string\n}\n\nconst NavContent: React.FC<NavContentProps> = ({\n  icon,\n  badgeContent,\n  label\n}) => {\n  const { isDesktop } = useBreakpoints()\n\n  if (badgeContent) {\n    if (isDesktop) {\n      return (\n        <>\n          {icon && <NavIcon icon={icon} />}\n          <NavText>{label}</NavText>\n          <Avatar\n            color=\"var(--errorColor)\"\n            textColor=\"var(--white)\"\n            size=\"xs\"\n            className=\"u-ml-auto u-mr-1\"\n          >\n            <span style={{ fontSize: '11px', lineHeight: '1rem' }}>\n              {badgeContent > 99 ? '99+' : badgeContent}\n            </span>\n          </Avatar>\n        </>\n      )\n    } else {\n      return (\n        <>\n          {icon && (\n            <Badge badgeContent={badgeContent} color=\"error\" withBorder={false}>\n              <NavIcon icon={icon} />\n            </Badge>\n          )}\n          <NavText>{label}</NavText>\n        </>\n      )\n    }\n  }\n\n  return (\n    <>\n      {icon && <NavIcon icon={icon} />}\n      <NavText>{label}</NavText>\n    </>\n  )\n}\n\nexport { NavContent }\n"
  },
  {
    "path": "src/modules/navigation/NavContext.jsx",
    "content": "import React, { createContext, useState, useContext } from 'react'\n\nexport const NavContext = createContext()\n\nexport const NavProvider = ({ children }) => {\n  const clickState = useState(null)\n\n  return (\n    <NavContext.Provider value={clickState}>{children}</NavContext.Provider>\n  )\n}\n\nexport const useNavContext = () => {\n  const context = useContext(NavContext)\n  if (context === undefined) {\n    throw new Error('useNavContext must be used within a NavProvider')\n  }\n  return context\n}\n"
  },
  {
    "path": "src/modules/navigation/NavItem.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { NavItem as UINavItem } from 'cozy-ui/transpiled/react/Nav'\nimport { useI18n } from 'twake-i18n'\n\nimport { NavContent } from '@/modules/navigation/NavContent'\nimport { NavLink } from '@/modules/navigation/NavLink'\n\n/**\n * Renders a navigation item with optional badge content and support for shared links.\n *\n * @component\n * @param {Object} props - The component props.\n * @param {string} [props.to] - The path to navigate to when the item is clicked.\n * @param {string|Object} [props.icon] - The icon to display next to the label.\n * @param {string} [props.label] - The text label for the navigation item.\n * @param {string} [props.forcedLabel] - The forced text label for the navigation item (optional).\n * @param {RegExp} [props.rx] - A RegExp to modify the path dynamically (optional).\n * @param {Object} [props.clickState] - State to be passed to the NavLink on click (optional).\n * @param {number} [props.badgeContent] - Content of the badge to display (optional).\n * @param {boolean} [props.secondary=false] - Whether to apply secondary styling to the nav item (optional).\n * @returns {JSX.Element} The rendered navigation item component.\n */\nconst NavItem = ({\n  to,\n  icon,\n  label,\n  rx,\n  clickState,\n  badgeContent,\n  secondary,\n  forcedLabel\n}) => {\n  const { t } = useI18n()\n\n  return (\n    <UINavItem secondary={secondary}>\n      <NavLink to={to} rx={rx} clickState={clickState}>\n        <NavContent\n          icon={icon}\n          label={forcedLabel ?? t(`Nav.item_${label}`)}\n          badgeContent={badgeContent}\n        />\n      </NavLink>\n    </UINavItem>\n  )\n}\n\nNavItem.propTypes = {\n  to: PropTypes.string,\n  icon: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),\n  label: PropTypes.string,\n  forcedLabel: PropTypes.string,\n  rx: PropTypes.shape(RegExp),\n  badgeContent: PropTypes.number\n}\n\nexport { NavItem }\n"
  },
  {
    "path": "src/modules/navigation/NavLink.jsx",
    "content": "import cx from 'classnames'\nimport PropTypes from 'prop-types'\nimport React from 'react'\nimport { useLocation } from 'react-router-dom'\n\nimport { NavLink as UINavLink } from 'cozy-ui/transpiled/react/Nav'\n\nimport { navLinkMatch } from '@/modules/navigation/helpers'\n\n/**\n * Like react-router NavLink but sets the lastClicked state (passed in props)\n * to have a faster change of active (not waiting for the route to completely\n * change).\n */\nconst NavLink = ({\n  children,\n  to,\n  rx,\n  clickState: [lastClicked, setLastClicked]\n}) => {\n  const location = useLocation()\n  const pathname = lastClicked ? lastClicked : location.pathname\n  const isActive = navLinkMatch(rx, to, pathname)\n  return (\n    <a\n      style={{ outline: 'none' }}\n      onClick={e => {\n        if (!to) e.preventDefault()\n        setLastClicked(to)\n      }}\n      href={`#${to}`}\n      className={cx(\n        UINavLink.className,\n        isActive ? UINavLink.activeClassName : null\n      )}\n    >\n      {children}\n    </a>\n  )\n}\n\nNavLink.propTypes = {\n  children: PropTypes.node.isRequired,\n  to: PropTypes.string,\n  rx: PropTypes.shape(RegExp)\n}\n\nexport { NavLink }\n"
  },
  {
    "path": "src/modules/navigation/PublicNoteRedirect.tsx",
    "content": "import React, { FC, useEffect, useState } from 'react'\nimport { useLocation, useParams } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport { fetchURL } from 'cozy-client/dist/models/note'\nimport Empty from 'cozy-ui/transpiled/react/Empty'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport SadCozyIcon from 'cozy-ui/transpiled/react/Icons/SadCozy'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\nimport { useI18n } from 'twake-i18n'\n\nimport { joinPath } from '@/lib/path'\nimport { DummyLayout } from '@/modules/layout/DummyLayout'\n\nconst PublicNoteRedirect: FC = () => {\n  const { t } = useI18n()\n  const { fileId, driveId } = useParams()\n  const { search } = useLocation()\n  const client = useClient()\n\n  const [noteUrl, setNoteUrl] = useState<string | null>(null)\n  const [fetchStatus, setFetchStatus] = useState<\n    'failed' | 'loading' | 'pending' | 'loaded'\n  >('pending')\n\n  useEffect(() => {\n    const fetchNoteUrl = async (fileId: string): Promise<void> => {\n      setFetchStatus('loading')\n\n      try {\n        // Inside notes, we need to add / at the end of /public/ or /preview/ to avoid 409 error\n        const searchParams = new URLSearchParams(search)\n        const returnUrl = searchParams.get('returnUrl')\n\n        const pathname =\n          location.pathname === '/'\n            ? '/public/'\n            : joinPath(location.pathname, '')\n        const url = await fetchURL(\n          client,\n          {\n            id: fileId\n          },\n          {\n            driveId,\n            pathname,\n            returnUrl\n          }\n        )\n        setNoteUrl(url)\n        setFetchStatus('loaded')\n      } catch (_error) {\n        setFetchStatus('failed')\n      }\n    }\n\n    if (fileId) {\n      void fetchNoteUrl(fileId)\n    }\n  }, [search, fileId, driveId, client])\n\n  if (noteUrl) {\n    // eslint-disable-next-line react-hooks/immutability\n    window.location.href = noteUrl\n  }\n\n  return (\n    <DummyLayout>\n      {fetchStatus === 'failed' && (\n        <Empty\n          icon={<Icon icon={SadCozyIcon} color=\"var(--primaryColor)\" />}\n          title={t('PublicNoteRedirect.error.title')}\n          text={t('PublicNoteRedirect.error.subtitle')}\n        />\n      )}\n      {fetchStatus !== 'failed' && <Spinner size=\"xxlarge\" middle noMargin />}\n    </DummyLayout>\n  )\n}\n\nexport { PublicNoteRedirect }\n"
  },
  {
    "path": "src/modules/navigation/SharingsNavItem.jsx",
    "content": "import React from 'react'\n\nimport { useQuery } from 'cozy-client'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ShareIcon from 'cozy-ui/transpiled/react/Icons/ShareExternal'\n\nimport { NavItem } from '@/modules/navigation/NavItem'\nimport { buildNewSharingShortcutQuery } from '@/queries'\n\nconst SharingsNavItem = ({ clickState }) => {\n  const newSharingShortcutQuery = buildNewSharingShortcutQuery()\n  const newSharingShortcutResult = useQuery(\n    newSharingShortcutQuery.definition,\n    newSharingShortcutQuery.options\n  )\n\n  return (\n    <NavItem\n      to=\"/sharings\"\n      icon={<Icon icon={ShareIcon} />}\n      label=\"sharings\"\n      rx={/\\/sharings(\\/.*)?/}\n      clickState={clickState}\n      badgeContent={newSharingShortcutResult.data?.length}\n    />\n  )\n}\n\nexport { SharingsNavItem }\n"
  },
  {
    "path": "src/modules/navigation/components/ExternalDriveListItem.tsx",
    "content": "import React, { FC } from 'react'\n\nimport { splitFilename } from 'cozy-client/dist/models/file'\nimport type { IOCozyFile } from 'cozy-client/types/types'\nimport FileTypeServerIcon from 'cozy-ui/transpiled/react/Icons/FileTypeServer'\nimport { NavIcon, NavLink, NavItem } from 'cozy-ui/transpiled/react/Nav'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\n\nimport { FileLink } from './FileLink'\n\nimport { useFileLink } from '@/modules/navigation/hooks/useFileLink'\n\ninterface ExternalDriveListItemProps {\n  file: IOCozyFile\n  setLastClicked: (value: string | undefined) => void\n}\n\nconst ExternalDriveListItem: FC<ExternalDriveListItemProps> = ({\n  file,\n  setLastClicked\n}) => {\n  const { link } = useFileLink(file, { forceFolderPath: false })\n  const { filename } = splitFilename(file)\n\n  return (\n    <NavItem key={file._id}>\n      <FileLink\n        link={link}\n        className={NavLink.className}\n        onClick={(): void => setLastClicked(undefined)}\n      >\n        <NavIcon icon={FileTypeServerIcon} />\n        <Typography variant=\"inherit\" color=\"inherit\" noWrap>\n          {filename}\n        </Typography>\n      </FileLink>\n    </NavItem>\n  )\n}\n\nexport { ExternalDriveListItem }\n"
  },
  {
    "path": "src/modules/navigation/components/ExternalDrivesList.tsx",
    "content": "import React, { FC } from 'react'\n\nimport { useQuery } from 'cozy-client'\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport List from 'cozy-ui/transpiled/react/List'\nimport ListSubheader from 'cozy-ui/transpiled/react/ListSubheader'\nimport { useI18n } from 'twake-i18n'\n\nimport { ExternalDriveListItem } from './ExternalDriveListItem'\n\nimport { buildExternalDrivesQuery } from '@/queries'\n\ninterface ExternalDriveListProps {\n  className?: string\n  clickState: [string, (value: string | undefined) => void]\n}\n\nconst ExternalDrives: FC<ExternalDriveListProps> = ({\n  className,\n  clickState\n}) => {\n  const { t } = useI18n()\n  const externalDrivesQuery = buildExternalDrivesQuery({\n    sortAttribute: 'name',\n    sortOrder: 'desc'\n  })\n  const externalDrivesResult = useQuery(\n    externalDrivesQuery.definition,\n    externalDrivesQuery.options\n  ) as {\n    data?: IOCozyFile[] | null\n  }\n\n  if (externalDrivesResult.data && externalDrivesResult.data.length > 0) {\n    return (\n      <List\n        subheader={\n          <ListSubheader>{t('Nav.item_external_drives')}</ListSubheader>\n        }\n        className={className}\n      >\n        {externalDrivesResult.data.map(file => (\n          <ExternalDriveListItem\n            key={file._id}\n            file={file}\n            setLastClicked={clickState[1]}\n          />\n        ))}\n      </List>\n    )\n  }\n\n  return null\n}\n\nexport { ExternalDrives }\n"
  },
  {
    "path": "src/modules/navigation/components/FileLink.tsx",
    "content": "import React, { forwardRef } from 'react'\nimport { Link } from 'react-router-dom'\n\nimport type { LinkResult } from '@/modules/navigation/hooks/useFileLink'\n\ninterface FileLinkProps {\n  link: LinkResult\n  children: React.ReactNode\n  [key: string]: unknown\n}\n\nconst FileLink = forwardRef<HTMLAnchorElement, FileLinkProps>(\n  function FileLinkComponent({ link, children, ...props }, ref) {\n    const openInNewTab = link.openInNewTab ? { target: '_blank' } : {}\n\n    if (link.app === 'drive') {\n      return (\n        <Link to={link.to} {...openInNewTab} {...props} ref={ref}>\n          {children}\n        </Link>\n      )\n    }\n\n    return (\n      <a href={link.href} {...openInNewTab} {...props} ref={ref}>\n        {children}\n      </a>\n    )\n  }\n)\n\nexport { FileLink }\n"
  },
  {
    "path": "src/modules/navigation/duck/actions.jsx",
    "content": "import React from 'react'\n\nimport { isDirectory } from 'cozy-client/dist/models/file'\nimport { resetQuery as resetQueryAction } from 'cozy-client/dist/store'\nimport flag from 'cozy-flags'\nimport { QuotaPaywall } from 'cozy-ui-plus/dist/Paywall'\n\nimport {\n  ROOT_DIR_ID,\n  TRASH_DIR_ID,\n  FILES_FETCH_LIMIT,\n  MAX_PAYLOAD_SIZE_IN_GB,\n  MAX_UPLOAD_FILE_COUNT\n} from '@/constants/config'\nimport { getEntriesTypeTranslated } from '@/lib/entries'\nimport logger from '@/lib/logger'\nimport { showModal } from '@/lib/react-cozy-helpers'\nimport { getFolderContent, getFolderContentQueries } from '@/modules/selectors'\nimport { addToUploadQueue, extractFilesEntries } from '@/modules/upload'\nimport UploadLimitDialog from '@/modules/upload/UploadLimitDialog'\n\nexport const SORT_FOLDER = 'SORT_FOLDER'\nexport const OPERATION_REDIRECTED = 'navigation/OPERATION_REDIRECTED'\n\nconst HTTP_CODE_CONFLICT = 409\n\nexport const operationRedirected = () => ({ type: OPERATION_REDIRECTED })\n\nexport const sortFolder = (folderId, sortAttribute, sortOrder = 'asc') => {\n  return {\n    type: SORT_FOLDER,\n    folderId,\n    sortAttribute,\n    sortOrder\n  }\n}\n\n/**\n * Reset folder queries so the server re-sends proper paginated data.\n * Works around cozy-client's sortAndLimitDocsIds capping realtime-added\n * documents to `limit * fetchedPagesCount`, which hides files beyond\n * the first page and leaves hasMore stale.\n */\nconst refetchFolderQueries = async (client, folderId) => {\n  try {\n    const storeState = client.store.getState()\n    const matchingQueries = getFolderContentQueries(storeState, folderId)\n\n    await Promise.all(\n      matchingQueries.map(async queryState => {\n        if (!queryState?.definition) return\n        // Clear stale pagination state then fetch every page\n        client.dispatch(resetQueryAction(queryState.id))\n        await client.queryAll(queryState.definition, { as: queryState.id })\n      })\n    )\n  } catch (error) {\n    logger.error('Failed to refetch folder queries after upload:', error)\n  }\n}\n\n/**\n * Upload files to the given directory\n * @param {Array} files - The list of File objects to upload\n * @param {string} dirId - The id of the directory in which we upload the files\n * @param {Object} sharingState - The sharing context (provided by SharingContext.Provider)\n * @param {function} fileUploadedCallback - A callback called when a file is uploaded\n * @param {Object} options - An object containing the following properties:\n *   - client - The cozy-client instance\n *   - showAlert - A function to show an alert\n *   - t - A translation function\n * @param {string|undefined} driveId - The id of the drive in which we upload the files\n * @param {function|undefined} addItems - Callback to add newly uploaded items to the context.\n */\nexport const uploadFiles =\n  (\n    files,\n    dirId,\n    sharingState,\n    fileUploadedCallback = () => null,\n    { client, showAlert, t },\n    driveId,\n    addItems\n  ) =>\n  async dispatch => {\n    let targetDirId = dirId\n    let navigateAfterUpload = false\n\n    if (dirId === null || dirId === undefined || dirId === TRASH_DIR_ID) {\n      targetDirId = ROOT_DIR_ID\n      navigateAfterUpload = true\n    }\n\n    const maxFileCount =\n      flag('drive.max-upload-file-count') ?? MAX_UPLOAD_FILE_COUNT\n\n    // Extract entries synchronously before browser clears dataTransfer\n    const entries = extractFilesEntries(files)\n\n    dispatch(\n      addToUploadQueue(\n        entries,\n        targetDirId,\n        sharingState,\n        fileUploadedCallback,\n        ({\n          createdItems,\n          quotas,\n          conflicts,\n          networkErrors,\n          errors,\n          unreadableErrors,\n          updatedItems,\n          fileTooLargeErrors\n        }) => {\n          dispatch(\n            uploadQueueProcessed(\n              createdItems,\n              quotas,\n              conflicts,\n              networkErrors,\n              errors,\n              unreadableErrors,\n              updatedItems,\n              showAlert,\n              t,\n              fileTooLargeErrors,\n              navigateAfterUpload,\n              addItems\n            )\n          )\n          if (createdItems.length + updatedItems.length >= FILES_FETCH_LIMIT) {\n            refetchFolderQueries(client, targetDirId)\n          }\n        },\n        {\n          client,\n          maxFileCount,\n          onLimitExceeded: () =>\n            dispatch(\n              showModal(<UploadLimitDialog maxFileCount={maxFileCount} />)\n            )\n        },\n        driveId,\n        addItems\n      )\n    )\n  }\n\nconst uploadQueueProcessed =\n  (\n    created,\n    quotas,\n    conflicts,\n    networkErrors,\n    errors,\n    unreadableErrors,\n    updated,\n    showAlert,\n    t,\n    fileTooLargeErrors,\n    navigateAfterUpload,\n    addItems\n  ) =>\n  dispatch => {\n    const safeAddItems = typeof addItems === 'function' ? addItems : () => {}\n    const conflictCount = conflicts.length\n    const createdCount = created.length\n    const updatedCount = updated.length\n    const type = getEntriesTypeTranslated(t, [\n      ...created,\n      ...updated,\n      ...conflicts\n    ])\n\n    // Add new items to the NewContext\n    const successfulUploads = [...created, ...updated]\n    if (successfulUploads.length > 0) {\n      safeAddItems(successfulUploads)\n    }\n\n    // Add logging to debug upload completion\n    logger.debug('uploadQueueProcessed called with:', {\n      created: created.map(f => f.name),\n      updated: updated.map(f => f.name),\n      quotas: quotas.map(f => f.name),\n      conflicts: conflicts.map(f => f.name),\n      networkErrors: networkErrors.map(f => f.name),\n      errors: errors.map(f => ({\n        name: f.name,\n        status: f.status,\n        message: f.message\n      })),\n      unreadableErrors: unreadableErrors.map(f => f.name),\n      fileTooLargeErrors: fileTooLargeErrors.map(f => f.name),\n      navigateAfterUpload\n    })\n\n    if (quotas.length > 0) {\n      logger.warn(`Upload module triggers a quota alert: ${quotas}`)\n      dispatch(\n        showModal(<QuotaPaywall isIapEnabled={flag('flagship.iap.enabled')} />)\n      )\n    } else if (networkErrors.length > 0) {\n      logger.warn(`Upload module triggers a network error: ${networkErrors}`)\n      showAlert({\n        message: t('upload.alert.network'),\n        severity: 'error',\n        duration: null,\n        noClickAway: true\n      })\n    } else if (unreadableErrors.length > 0) {\n      logger.warn(\n        `Upload module triggers an unreadable files error: ${unreadableErrors}`\n      )\n      showAlert({\n        message: t('upload.alert.unreadable_files'),\n        severity: 'error',\n        duration: null,\n        noClickAway: true\n      })\n    } else if (errors.length > 0) {\n      logger.error(`Upload module triggers an error: ${errors}`)\n      showAlert({\n        message: t('upload.alert.errors', { type }),\n        severity: 'error',\n        duration: null,\n        noClickAway: true\n      })\n    } else if (updatedCount > 0 && createdCount > 0 && conflictCount > 0) {\n      showAlert({\n        message: t('upload.alert.success_updated_conflicts', {\n          smart_count: createdCount,\n          updatedCount,\n          conflictCount,\n          type\n        }),\n        severity: 'success'\n      })\n    } else if (updatedCount > 0 && createdCount > 0) {\n      showAlert({\n        message: t('upload.alert.success_updated', {\n          smart_count: createdCount,\n          updatedCount,\n          type\n        }),\n        severity: 'success'\n      })\n    } else if (updatedCount > 0 && conflictCount > 0) {\n      showAlert({\n        message: t('upload.alert.updated_conflicts', {\n          smart_count: updatedCount,\n          conflictCount,\n          type\n        }),\n        severity: 'success'\n      })\n    } else if (conflictCount > 0) {\n      showAlert({\n        message: t('upload.alert.success_conflicts', {\n          smart_count: createdCount,\n          conflictNumber: conflictCount,\n          type\n        }),\n        severity: 'secondary'\n      })\n    } else if (updatedCount > 0 && createdCount === 0) {\n      showAlert({\n        message: t('upload.alert.updated', {\n          smart_count: updatedCount,\n          type\n        }),\n        severity: 'success'\n      })\n    } else if (fileTooLargeErrors.length > 0) {\n      showAlert({\n        message: t('upload.alert.fileTooLargeErrors', {\n          max_size_value: MAX_PAYLOAD_SIZE_IN_GB\n        }),\n        severity: 'error',\n        duration: null,\n        noClickAway: true\n      })\n    } else {\n      showAlert({\n        message: t('upload.alert.success', {\n          smart_count: createdCount,\n          type\n        }),\n        severity: 'success'\n      })\n    }\n\n    const hasSuccessfulUploads = created.length > 0 || updated.length > 0\n\n    if (navigateAfterUpload && hasSuccessfulUploads) {\n      logger.debug('Dispatching operationRedirected for upload.')\n      dispatch(operationRedirected())\n    } else {\n      logger.debug('Not dispatching operationRedirected for upload.', {\n        navigateAfterUpload,\n        hasSuccessfulUploads\n      })\n    }\n  }\n\n/**\n * Given a folderId, checks the current known state to return if\n * a folder with the same name exist in the given folderId.\n *\n * The local state can be incomplete so this can return false\n * negatives.\n */\nconst doesFolderExistByName = (state, parentFolderId, name) => {\n  const filesInCurrentView = getFolderContent(state, parentFolderId) || [] // TODO in the public view we don't use a query, so getFolderContent returns null. We could look inside the cozy-client store with a predicate to find folders with a matching dir_id.\n\n  const existingFolder = filesInCurrentView.find(f => {\n    return isDirectory(f) && f.name === name\n  })\n\n  return Boolean(existingFolder)\n}\n\n/**\n * Creates a folder in the current view\n */\nexport const createFolder = (\n  client,\n  name,\n  currentFolderId,\n  { showAlert, t } = {},\n  driveId,\n  addItems = () => {}\n) => {\n  const safeAddItems = typeof addItems === 'function' ? addItems : () => {}\n  return async (dispatch, getState) => {\n    const state = getState()\n    let targetFolderId = currentFolderId\n    let navigateAfterCreate = false\n\n    if (\n      currentFolderId === null ||\n      currentFolderId === undefined ||\n      currentFolderId === TRASH_DIR_ID\n    ) {\n      targetFolderId = ROOT_DIR_ID\n      navigateAfterCreate = true\n    }\n\n    const existingFolder = doesFolderExistByName(state, targetFolderId, name)\n\n    if (existingFolder) {\n      showAlert({\n        message: t('alert.folder_name', { folderName: name }),\n        severity: 'error'\n      })\n      throw new Error('alert.folder_name')\n    }\n\n    let createdFolder\n    try {\n      createdFolder = await client\n        .collection('io.cozy.files', { driveId })\n        .create({\n          name: name,\n          dirId: targetFolderId,\n          type: 'directory'\n        })\n\n      if (createdFolder) {\n        safeAddItems([createdFolder.data])\n      }\n\n      if (navigateAfterCreate && createdFolder) {\n        dispatch(operationRedirected())\n      }\n    } catch (err) {\n      if (err.response && err.response.status === HTTP_CODE_CONFLICT) {\n        showAlert({\n          message: t('alert.folder_name', { folderName: name }),\n          severity: 'error'\n        })\n      } else {\n        showAlert({ message: t('alert.folder_generic'), severity: 'error' })\n      }\n      throw err\n    }\n  }\n}\n"
  },
  {
    "path": "src/modules/navigation/duck/actions.spec.jsx",
    "content": "import CozyClient from 'cozy-client'\nimport flag from 'cozy-flags'\n\nimport { createFolder, uploadFiles } from './actions'\nimport { generateFile } from 'test/generate'\nimport { setupFolderContent } from 'test/setup'\n\nimport { addToUploadQueue, extractFilesEntries } from '@/modules/upload'\n\njest.mock('cozy-flags', () => jest.fn(() => null))\n\njest.mock('@/modules/upload', () => ({\n  addToUploadQueue: jest.fn(() => () => {}),\n  extractFilesEntries: jest.fn()\n}))\n\njest.mock('@/modules/upload/UploadLimitDialog', () => {\n  const React = require('react')\n  return function MockUploadLimitDialog(props) {\n    return React.createElement('div', {\n      'data-testid': 'upload-limit-dialog',\n      ...props\n    })\n  }\n})\n\nconst showAlert = jest.fn()\nconst t = x => x\n\nbeforeEach(() => {\n  const folders = Array(3)\n    .fill(null)\n    .map((x, i) => generateFile({ i, type: 'directory' }))\n  const files = Array(3)\n    .fill(null)\n    .map((x, i) => generateFile({ i }))\n  jest.spyOn(CozyClient.prototype, 'requestQuery').mockResolvedValue({\n    data: files.concat(folders)\n  })\n})\n\nafterEach(() => {\n  CozyClient.prototype.requestQuery.mockRestore()\n})\n\ndescribe('createFolder', () => {\n  beforeEach(() => {\n    jest.spyOn(CozyClient.prototype, 'create').mockImplementation(() => {})\n    jest.spyOn(CozyClient.prototype, 'collection').mockReturnValue({\n      create: jest.fn().mockResolvedValue({})\n    })\n  })\n\n  afterEach(() => {\n    CozyClient.prototype.create.mockRestore()\n    CozyClient.prototype.collection.mockRestore()\n  })\n\n  it('should not be possible to create a folder with a same name of an existing folder', async () => {\n    const folderId = 'folder123456'\n    const { client, store } = await setupFolderContent({\n      folderId\n    })\n    await expect(\n      store.dispatch(\n        createFolder(client, 'foobar2', folderId, { showAlert, t })\n      )\n    ).rejects.toEqual(new Error('alert.folder_name'))\n  })\n\n  it('should be possible to create a folder', async () => {\n    const folderId = 'folder123456'\n    const { client, store } = await setupFolderContent({\n      folderId\n    })\n\n    await store.dispatch(\n      createFolder(client, 'foobar5', folderId, { showAlert, t })\n    )\n\n    expect(client.collection).toHaveBeenCalledWith('io.cozy.files', {\n      driveId: undefined\n    })\n    const mockCollection = client.collection.mock.results[0].value\n    expect(mockCollection.create).toHaveBeenCalledWith({\n      dirId: 'folder123456',\n      name: 'foobar5',\n      type: 'directory'\n    })\n  })\n})\n\ndescribe('uploadFiles', () => {\n  const mockFiles = [new File([''], 'test.txt')]\n  const mockEntries = [{ file: mockFiles[0], isDirectory: false, entry: null }]\n  const deps = {\n    client: {},\n    showAlert: jest.fn(),\n    t: x => x\n  }\n\n  beforeEach(() => {\n    jest.clearAllMocks()\n    extractFilesEntries.mockReturnValue(mockEntries)\n    flag.mockReturnValue(null)\n  })\n\n  const getAddToUploadQueueOptions = () => addToUploadQueue.mock.calls[0][5]\n\n  it('passes the flag-driven limit and a modal-opening onLimitExceeded callback', async () => {\n    flag.mockReturnValue(100)\n\n    const dispatch = jest.fn()\n    await uploadFiles(mockFiles, 'dir-id', {}, () => null, deps)(dispatch)\n\n    const options = getAddToUploadQueueOptions()\n    expect(options).toMatchObject({ client: deps.client, maxFileCount: 100 })\n    expect(typeof options.onLimitExceeded).toBe('function')\n\n    options.onLimitExceeded()\n    expect(dispatch).toHaveBeenCalledWith(\n      expect.objectContaining({ type: 'SHOW_MODAL' })\n    )\n  })\n\n  it('falls back to the default limit when no flag is set', async () => {\n    flag.mockReturnValue(null)\n\n    const dispatch = jest.fn()\n    await uploadFiles(mockFiles, 'dir-id', {}, () => null, deps)(dispatch)\n\n    expect(getAddToUploadQueueOptions()).toMatchObject({ maxFileCount: 500 })\n  })\n\n  it('does not show the modal eagerly', async () => {\n    const dispatch = jest.fn()\n    await uploadFiles(mockFiles, 'dir-id', {}, () => null, deps)(dispatch)\n\n    expect(dispatch).not.toHaveBeenCalledWith(\n      expect.objectContaining({ type: 'SHOW_MODAL' })\n    )\n  })\n\n  it('passes pre-extracted entries to addToUploadQueue', async () => {\n    const dispatch = jest.fn()\n    await uploadFiles(mockFiles, 'dir-id', {}, () => null, deps)(dispatch)\n\n    expect(extractFilesEntries).toHaveBeenCalledWith(mockFiles)\n    expect(addToUploadQueue).toHaveBeenCalledWith(\n      mockEntries,\n      'dir-id',\n      expect.anything(),\n      expect.anything(),\n      expect.anything(),\n      expect.objectContaining({ client: deps.client }),\n      undefined,\n      undefined\n    )\n  })\n})\n"
  },
  {
    "path": "src/modules/navigation/duck/async.js",
    "content": "export const extractFileAttributes = f => {\n  const id = f.id || f._id\n  return {\n    ...f.attributes,\n    id,\n    _id: id,\n    _type: 'io.cozy.files',\n    links: f.links,\n    relationships: f.relationships\n  }\n}\n"
  },
  {
    "path": "src/modules/navigation/duck/index.js",
    "content": "export { default, getSort } from './reducer'\n\nexport { sortFolder, uploadFiles, createFolder } from './actions'\n\nexport { getSharingIdFromUrl } from './utils'\n"
  },
  {
    "path": "src/modules/navigation/duck/reducer.js",
    "content": "import { combineReducers } from 'redux'\n\nimport { SORT_FOLDER, OPERATION_REDIRECTED } from './actions'\n\n// Action type for resetting the flag\nexport const RESET_OPERATION_REDIRECTED =\n  'navigation/RESET_OPERATION_REDIRECTED'\n\nconst sort = (state = null, action) => {\n  switch (action.type) {\n    case SORT_FOLDER:\n      return {\n        attribute: action.sortAttribute,\n        order: action.sortOrder\n      }\n    default:\n      return state\n  }\n}\n\n// Reducer for the redirection flag\nconst operationRedirectedReducer = (state = false, action) => {\n  switch (action.type) {\n    case OPERATION_REDIRECTED:\n      return true\n    case RESET_OPERATION_REDIRECTED:\n      return false\n    default:\n      return state\n  }\n}\n\nexport default combineReducers({\n  sort,\n  operationRedirected: operationRedirectedReducer // Add the reducer\n})\n\n/**\n * Retrieves the sort value from the view object.\n *\n * @param {Object} state - The state object containing the view property.\n * @returns {string} The sort value.\n */\n// Selector needs to point to the correct state slice (`view`)\nexport const getSort = state => state.view.sort\n\n// Selector for the state (`view`)\nexport const wasOperationRedirected = state => state.view.operationRedirected\n"
  },
  {
    "path": "src/modules/navigation/duck/utils.js",
    "content": "export const getSharingIdFromUrl = url => {\n  const urlSearchParams = new URLSearchParams(url.search)\n  return urlSearchParams.get('sharing')\n}\n"
  },
  {
    "path": "src/modules/navigation/duck/utils.spec.js",
    "content": "import { getSharingIdFromUrl } from './utils'\n\ndescribe('getSharingIdFromUrl', () => {\n  it('should return sharing id from url', () => {\n    const urlWithoutSharingId = new URL('https://test.mycozy.cloud/')\n    const urlWithSharingId = new URL('https://test.mycozy.cloud/?sharing=123')\n\n    expect(getSharingIdFromUrl(urlWithoutSharingId)).toBeNull()\n    expect(getSharingIdFromUrl(urlWithSharingId)).toBe('123')\n  })\n})\n"
  },
  {
    "path": "src/modules/navigation/helpers.js",
    "content": "/**\n * Returns true if `to` and `pathname` match\n * Supports `rx` for regex matches.\n */\nexport const navLinkMatch = (rx, to, pathname) => {\n  return rx ? rx.test(pathname) : pathname.slice(1) === to\n}\n"
  },
  {
    "path": "src/modules/navigation/hooks/helpers.spec.js",
    "content": "import { computeFileType, computeApp, computePath } from './helpers'\n\nimport { TRASH_DIR_ID, SHARED_DRIVES_DIR_ID } from '@/constants/config'\nimport { makeOnlyOfficeFileRoute } from '@/modules/views/OnlyOffice/helpers'\n\njest.mock('modules/views/OnlyOffice/helpers', () => ({\n  makeOnlyOfficeFileRoute: jest.fn()\n}))\njest.mock('cozy-flags', () => jest.fn())\n\ndescribe('computeFileType', () => {\n  beforeEach(() => {\n    jest.clearAllMocks()\n  })\n\n  it('should return \"trash\" for the trash directory', () => {\n    const file = { _id: TRASH_DIR_ID }\n    expect(computeFileType(file)).toBe('trash')\n  })\n\n  it('should return \"nextcloud-trash\" for Nextcloud trash directory', () => {\n    const file = { _id: 'io.cozy.remote.nextcloud.files.trash-dir' }\n    expect(computeFileType(file)).toBe('nextcloud-trash')\n  })\n\n  it('should return \"shared-drive\" for files in shared drives directory', () => {\n    const file = {\n      dir_id: SHARED_DRIVES_DIR_ID,\n      _type: 'io.cozy.files',\n      type: 'file'\n    }\n    expect(computeFileType(file)).toBe('shared-drive')\n  })\n\n  it('should return \"nextcloud-directory\" for Nextcloud directories', () => {\n    const file = { _type: 'io.cozy.remote.nextcloud.files', type: 'directory' }\n    expect(computeFileType(file)).toBe('nextcloud-directory')\n  })\n\n  it('should return \"nextcloud-file\" for Nextcloud files', () => {\n    const file = { _type: 'io.cozy.remote.nextcloud.files', type: 'file' }\n    expect(computeFileType(file)).toBe('nextcloud-file')\n  })\n\n  it('should return \"public-note-same-instance\" for public notes on the same instance', () => {\n    const file = {\n      _type: 'io.cozy.files',\n      name: 'My journal.cozy-note',\n      type: 'file',\n      metadata: {\n        title: '',\n        version: '0'\n      },\n      cozyMetadata: {\n        createdOn: 'https://example.com/'\n      }\n    }\n    expect(\n      computeFileType(file, { isPublic: true, cozyUrl: 'https://example.com' })\n    ).toBe('public-note-same-instance')\n  })\n\n  it('should return \"note\" for notes on the same instance', () => {\n    const file = {\n      _type: 'io.cozy.files',\n      name: 'My journal.cozy-note',\n      type: 'file',\n      metadata: {\n        title: '',\n        version: '0'\n      },\n      cozyMetadata: {\n        createdOn: 'https://example.com/'\n      }\n    }\n    expect(computeFileType(file, { cozyUrl: 'https://example.com/' })).toBe(\n      'note'\n    )\n  })\n\n  it('should return \"public-note\" for notes on an another instance', () => {\n    const file = {\n      _type: 'io.cozy.files',\n      name: 'My journal.cozy-note',\n      type: 'file',\n      metadata: {\n        title: '',\n        version: '0'\n      },\n      cozyMetadata: {\n        createdOn: 'https://example.com/'\n      }\n    }\n    expect(computeFileType(file, { cozyUrl: 'https://another.com/' })).toBe(\n      'public-note'\n    )\n  })\n\n  it('should return \"public-note\" for public notes', () => {\n    const file = {\n      _type: 'io.cozy.files',\n      name: 'My journal.cozy-note',\n      type: 'file',\n      metadata: {\n        title: '',\n        version: '0'\n      },\n      cozyMetadata: {\n        createdOn: 'https://example.com/'\n      }\n    }\n    expect(\n      computeFileType(file, { isPublic: true, cozyUrl: 'https://another.com' })\n    ).toBe('public-note')\n  })\n\n  it('should return \"onlyoffice\" for files opened by OnlyOffice when Office is enabled', () => {\n    const file = {\n      _type: 'io.cozy.files',\n      class: 'text',\n      name: 'My document.docx',\n      type: 'file'\n    }\n    expect(computeFileType(file, { isOfficeEnabled: true })).toBe('onlyoffice')\n  })\n\n  it('should return \"file\" for files opened by OnlyOffice when Office is disabled', () => {\n    const file = {\n      _type: 'io.cozy.files',\n      class: 'text',\n      name: 'My document.docx',\n      type: 'file'\n    }\n    expect(computeFileType(file, { isOfficeEnabled: false })).toBe('file')\n  })\n\n  it('should return \"file\" for files that OnlyOffice can\\'t open (.txt, .md)', () => {\n    const file = {\n      _type: 'io.cozy.files',\n      class: 'text',\n      name: 'My markdown.md',\n      type: 'file'\n    }\n    expect(computeFileType(file, { isOfficeEnabled: true })).toBe('file')\n  })\n\n  it('should return \"nextcloud\" for Nextcloud shortcuts', () => {\n    const file = {\n      _type: 'io.cozy.files',\n      class: 'shortcut',\n      cozyMetadata: {\n        createdByApp: 'nextcloud'\n      }\n    }\n    expect(computeFileType(file)).toBe('nextcloud')\n  })\n\n  it('should return \"shortcut\" for other shortcuts', () => {\n    const file = { _type: 'io.cozy.files', class: 'shortcut' }\n    expect(computeFileType(file)).toBe('shortcut')\n  })\n\n  it('should return \"directory\" for directories', () => {\n    const file = { _type: 'io.cozy.files', type: 'directory' }\n    expect(computeFileType(file)).toBe('directory')\n  })\n\n  it('should return \"file\" for other files', () => {\n    const file = { _type: 'io.cozy.files', type: 'file' }\n    expect(computeFileType(file)).toBe('file')\n  })\n})\n\ndescribe('computeApp', () => {\n  it('should return \"nextcloud\" for \"nextcloud-file\" type', () => {\n    expect(computeApp('nextcloud-file')).toBe('nextcloud')\n  })\n\n  it('should return \"notes\" for \"note\" type', () => {\n    expect(computeApp('note')).toBe('notes')\n  })\n\n  it('should return \"drive\" for any other types', () => {\n    expect(computeApp('unknown-type')).toBe('drive')\n    expect(computeApp('file')).toBe('drive')\n  })\n})\n\ndescribe('computePath', () => {\n  it('should return correct path for trash', () => {\n    expect(computePath({}, { type: 'trash', pathname: '/any/path' })).toBe(\n      '/trash'\n    )\n  })\n\n  it('should return correct path for nextcloud-trash', () => {\n    expect(\n      computePath({}, { type: 'nextcloud-trash', pathname: '/some/path' })\n    ).toBe('/some/path/trash')\n  })\n\n  it('should return correct path for nextcloud', () => {\n    const file = { cozyMetadata: { sourceAccount: 'account1' } }\n    expect(computePath(file, { type: 'nextcloud', pathname: '/any' })).toBe(\n      '/nextcloud/account1'\n    )\n  })\n\n  it('should return correct path for nextcloud-directory', () => {\n    const file = { path: '/folder' }\n    expect(\n      computePath(file, { type: 'nextcloud-directory', pathname: '/some/path' })\n    ).toBe('/some/path?path=/folder')\n  })\n\n  it('should return correct path for nextcloud-file', () => {\n    const file = { links: { self: '/file/link' } }\n    expect(\n      computePath(file, { type: 'nextcloud-file', pathname: '/any' })\n    ).toBe('/file/link')\n  })\n\n  it('should return correct path for note', () => {\n    const file = { _id: 'note123' }\n    expect(computePath(file, { type: 'note', pathname: '/any' })).toBe(\n      '/n/note123'\n    )\n  })\n\n  it('should return correct path for public-note', () => {\n    const file = { _id: 'note123' }\n    expect(\n      computePath(file, { type: 'public-note', pathname: '/public' })\n    ).toBe('/note/note123')\n  })\n\n  it('should return correct path for public-note with driveId in shared drive', () => {\n    const file = { _id: 'note123', driveId: 'drive456' }\n    expect(\n      computePath(file, { type: 'public-note', pathname: '/public' })\n    ).toBe('/note/drive456/note123?returnUrl=')\n  })\n\n  it('should return correct path for public-note-same-instance', () => {\n    const file = { _id: 'note123' }\n    expect(\n      computePath(file, {\n        type: 'public-note-same-instance',\n        pathname: '/public'\n      })\n    ).toBe('/?id=note123')\n  })\n\n  it('should return correct path for shortcut', () => {\n    const file = { _id: 'shortcut123' }\n    expect(computePath(file, { type: 'shortcut', pathname: '/any' })).toBe(\n      '/external/shortcut123'\n    )\n  })\n\n  it('should return correct path for directory at root', () => {\n    const file = { _id: 'dir123' }\n    expect(computePath(file, { type: 'directory', pathname: '/root' })).toBe(\n      'dir123'\n    )\n  })\n\n  it('should return correct path for nested directory', () => {\n    const file = { _id: 'dir123' }\n    expect(\n      computePath(file, { type: 'directory', pathname: '/root/nested' })\n    ).toBe('../dir123')\n  })\n\n  it('should return correct path for onlyoffice', () => {\n    const file = { _id: 'file123' }\n    makeOnlyOfficeFileRoute.mockReturnValue('/onlyoffice/route')\n    expect(\n      computePath(file, {\n        type: 'onlyoffice',\n        pathname: '/some/path',\n        isPublic: true\n      })\n    ).toBe('/onlyoffice/route')\n    expect(makeOnlyOfficeFileRoute).toHaveBeenCalledWith('file123', {\n      fromPathname: '/some/path',\n      fromPublicFolder: true\n    })\n  })\n\n  it('should return correct path for shared-drive', () => {\n    const file = { _id: 'file123', driveId: 'drive456' }\n    expect(computePath(file, { type: 'shared-drive', pathname: '/any' })).toBe(\n      '/shareddrive/drive456/file123'\n    )\n  })\n\n  it('should return correct for shared-drive in case user is owner', () => {\n    const file = { _id: 'file123' }\n    expect(computePath(file, { type: 'shared-drive', pathname: '/any' })).toBe(\n      '/folder/file123'\n    )\n  })\n\n  it('should return correct path for shared-drive-file', () => {\n    const file = {\n      _id: 'file123',\n      dir_id: 'dir456',\n      driveId: 'drive789',\n      _type: 'io.cozy.files'\n    }\n    expect(\n      computePath(file, { type: 'shared-drive-file', pathname: '/any' })\n    ).toBe('/shareddrive/drive789/dir456/file/file123')\n  })\n\n  it('should throw error for shared-drive-file without driveId', () => {\n    const file = {\n      _id: 'file123',\n      dir_id: 'dir456',\n      _type: 'io.cozy.files'\n    }\n    expect(() =>\n      computePath(file, { type: 'shared-drive-file', pathname: '/any' })\n    ).toThrow('Missing driveId or invalid file type in shared drive file')\n  })\n\n  it('should throw error for shared-drive-file without dir_id', () => {\n    const file = {\n      _id: 'file123',\n      driveId: 'drive789',\n      _type: 'io.cozy.files'\n    }\n    expect(() =>\n      computePath(file, { type: 'shared-drive-file', pathname: '/any' })\n    ).toThrow('Missing dir_id in shared drive file')\n  })\n\n  it('should return correct path for default case', () => {\n    const file = { _id: 'file123' }\n    expect(computePath(file, { type: 'unknown', pathname: '/any' })).toBe(\n      'file/file123'\n    )\n  })\n})\n"
  },
  {
    "path": "src/modules/navigation/hooks/helpers.ts",
    "content": "import CozyClient from 'cozy-client'\nimport {\n  isShortcut,\n  isNote,\n  isDocs,\n  shouldBeOpenedByOnlyOffice,\n  isDirectory\n} from 'cozy-client/dist/models/file'\nimport { IOCozyFile } from 'cozy-client/types/types'\n\nimport type { File } from '@/components/FolderPicker/types'\nimport { TRASH_DIR_ID, SHARED_DRIVES_DIR_ID } from '@/constants/config'\nimport { joinPath } from '@/lib/path'\nimport {\n  isNextcloudShortcut,\n  isNextcloudFile\n} from '@/modules/nextcloud/helpers'\nimport { makeSharedDriveNoteReturnUrl } from '@/modules/shareddrives/helpers'\nimport { makeOnlyOfficeFileRoute } from '@/modules/views/OnlyOffice/helpers'\n\ninterface ComputeFileTypeOptions {\n  isOfficeEnabled?: boolean\n  isPublic?: boolean\n  cozyUrl?: string\n}\n\ninterface ComputePathOptions {\n  type: string\n  pathname: string\n  isPublic: boolean\n  client: CozyClient | null\n}\n\nexport const computeFileType = (\n  file: File,\n  {\n    isOfficeEnabled = false,\n    isPublic = false,\n    cozyUrl = ''\n  }: ComputeFileTypeOptions = {}\n): string => {\n  if (file._id === TRASH_DIR_ID) {\n    return 'trash'\n  } else if (file._id === 'io.cozy.remote.nextcloud.files.trash-dir') {\n    return 'nextcloud-trash'\n  } else if (\n    file.dir_id === SHARED_DRIVES_DIR_ID &&\n    !isNextcloudShortcut(file)\n  ) {\n    return 'shared-drive'\n  } else if (file._type === 'io.cozy.remote.nextcloud.files') {\n    return isDirectory(file) ? 'nextcloud-directory' : 'nextcloud-file'\n  } else if (isNote(file)) {\n    // createdOn url ends with a trailing slash whereas cozyUrl does not joinPath fixes this\n    const isSameInstance =\n      joinPath(cozyUrl, '') === file.cozyMetadata?.createdOn\n\n    if (isPublic && isSameInstance) {\n      return 'public-note-same-instance'\n    } else if (isSameInstance) {\n      return 'note'\n    } else {\n      return 'public-note'\n    }\n  } else if (isDocs(file)) {\n    return 'docs'\n  } else if (shouldBeOpenedByOnlyOffice(file) && isOfficeEnabled) {\n    return 'onlyoffice'\n  } else if (isNextcloudShortcut(file)) {\n    return 'nextcloud'\n  } else if (isShortcut(file)) {\n    return 'shortcut'\n  } else if (isDirectory(file)) {\n    return 'directory'\n  } else if (file.driveId) {\n    return 'shared-drive-file'\n  } else {\n    return 'file'\n  }\n}\n\nexport const computeApp = (type: string): string => {\n  switch (type) {\n    case 'nextcloud-file':\n      return 'nextcloud'\n    case 'note':\n    case 'public-note-same-instance':\n      return 'notes'\n    case 'docs':\n      return 'docs'\n    default:\n      return 'drive'\n  }\n}\n\nexport const computePath = (\n  file: File,\n  { type, pathname, isPublic, client }: ComputePathOptions\n): string => {\n  const paths = pathname.split('/').slice(1)\n  const driveId = file.driveId as string | undefined\n\n  switch (type) {\n    case 'trash':\n      return '/trash'\n    case 'nextcloud-trash':\n      return `${pathname}/trash`\n    case 'nextcloud':\n      return `/nextcloud/${file.cozyMetadata?.sourceAccount ?? 'unknown'}`\n    case 'nextcloud-directory':\n      return `${pathname}?path=${file.path ?? '/'}`\n    case 'nextcloud-file':\n      // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access\n      return file.links?.self ?? ''\n    case 'note':\n      return `/n/${file._id}`\n    case 'public-note-same-instance':\n      return `/?id=${file._id}`\n    case 'public-note':\n      if (driveId) {\n        const returnUrl = client\n          ? makeSharedDriveNoteReturnUrl(client, file as IOCozyFile)\n          : ''\n\n        return `/note/${driveId}/${file._id}?returnUrl=${encodeURIComponent(\n          returnUrl\n        )}`\n      } else {\n        return `/note/${file._id}`\n      }\n    case 'docs':\n      return `/bridge/docs/${(file as IOCozyFile).metadata.externalId}`\n    case 'shortcut':\n      return `/external/${file._id}`\n    case 'directory':\n      // On mobile, if we are in /favorites tab, we do not want it to appears in computed path\n      // so we redirect to root route for folders\n      if (pathname.startsWith('/favorites')) {\n        return `/folder/${file._id}`\n      }\n      // paths with only one element correspond to the root of a page like /sharings\n      // when we add id we want to keep the path before to make /sharings/id\n      return paths.length === 1 ? file._id : `../${file._id}`\n    case 'onlyoffice':\n      return makeOnlyOfficeFileRoute(file._id, {\n        driveId,\n        fromPathname: pathname,\n        fromPublicFolder: isPublic\n      })\n    case 'shared-drive':\n      // Without driveId, we should use path `/folder/:folderId` because it's shared drive folder of owner\n      if (!driveId) {\n        return `/folder/${file._id}`\n      }\n\n      return `/shareddrive/${driveId}/${file._id}`\n    case 'shared-drive-file':\n      if (!driveId || isNextcloudFile(file)) {\n        throw new Error(\n          'Missing driveId or invalid file type in shared drive file'\n        )\n      }\n      if (!file.dir_id) {\n        throw new Error('Missing dir_id in shared drive file')\n      }\n      return `/shareddrive/${driveId}/${file.dir_id}/file/${file._id}`\n    default:\n      // On mobile, if we are in /favorites tab, we do not want it to appears in computed path\n      // so we redirect to root route for files\n      if (pathname.startsWith('/favorites')) {\n        return `/folder/${file.dir_id}/file/${file._id}`\n      }\n\n      return `file/${file._id}`\n  }\n}\n"
  },
  {
    "path": "src/modules/navigation/hooks/useFileLink.tsx",
    "content": "import { useCallback } from 'react'\nimport { useLocation, useResolvedPath, useNavigate } from 'react-router-dom'\nimport type { Path } from 'react-router-dom'\n\nimport { useClient, generateWebLink } from 'cozy-client'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport type { File } from '@/components/FolderPicker/types'\nimport { joinPath } from '@/lib/path'\nimport {\n  computeFileType,\n  computeApp,\n  computePath\n} from '@/modules/navigation/hooks/helpers'\nimport { usePublicContext } from '@/modules/public/PublicProvider'\nimport { getFolderPath } from '@/modules/routeUtils'\nimport { isOfficeEnabled as computeOfficeEnabled } from '@/modules/views/OnlyOffice/helpers'\n\nexport interface LinkResult {\n  app: string\n  href: string\n  to: Path\n  openInNewTab: boolean\n  isSharedDrive: boolean\n}\n\ninterface UseFileLinkResult {\n  link: LinkResult\n  openLink: (evt: React.MouseEvent<HTMLElement>) => void\n}\n\n/**\n * useFileLink computes the link to open a file.\n *\n * forceFolderPath is used to force `/folder` in the path\n *\n * To categories files requires different logic for the moment we can distinguishing 10 different cases. You can find the full list in the computeFileType function.\n *\n * Based on this category, we can compute the path to open the file. This path is relative so in case it will be used inside Drive we need to resolve it to use it inside generateWebLink. To work with relative path allows us to use the same logic for both cases (eg. recent, sharing pages)\n *\n * After we will make two types of links:\n * - to: will be used to open the file inside Drive as it based on react-router-dom convention\n * - href: which is regular href that can be used inside a link\n *\n * The first one is useful for link inside Drive and the second one for link outside of external application (eg. Notes, Nextcloud) or that will be opened in a new tab be default.\n *\n */\nconst useFileLink = (\n  file: File,\n  { forceFolderPath }: { forceFolderPath?: boolean } = {}\n): UseFileLinkResult => {\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n  const client = useClient()\n  const { isDesktop } = useBreakpoints()\n  const isOfficeEnabled = computeOfficeEnabled(isDesktop)\n  const { isPublic } = usePublicContext()\n\n  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n  const cozyUrl = client?.getStackClient().uri as string\n\n  const type = computeFileType(file, {\n    isOfficeEnabled,\n    isPublic,\n    cozyUrl\n  })\n  const app = computeApp(type)\n  const path = computePath(file, {\n    type,\n    pathname,\n    isPublic,\n    client\n  })\n\n  const shouldBeOpenedInNewTab =\n    type === 'shortcut' || type === 'nextcloud-file'\n\n  const currentURL = new URL(window.location.href)\n  const currentPathname = currentURL.pathname\n  const currentSearchParams = currentURL.searchParams\n\n  // we use relative path because by default react-router-dom will use the structure of routes\n  // each level of the path don't have a route but we want to move relatively to the path\n  // to have more explanation : https://reactrouter.com/en/main/components/link#relative\n  let to = useResolvedPath(path, {\n    relative: forceFolderPath ? 'route' : 'path'\n  })\n  if (forceFolderPath && !shouldBeOpenedInNewTab) {\n    to = {\n      ...to,\n      pathname:\n        (type === 'directory' ? '/folder' : getFolderPath(file.dir_id)) +\n        to.pathname\n    }\n  }\n\n  // we need to merge the searchParams of the current url and the new one created in computed path\n  // for example, to keep the sharecode in public context\n  const searchParams = new URLSearchParams({\n    ...Object.fromEntries(currentSearchParams.entries()),\n    ...Object.fromEntries(new URLSearchParams(to.search).entries())\n  })\n\n  // nextcloud-file is a special case because Nextcloud are not in cozy ecosystem\n  // so we open their link directly\n  const href =\n    type === 'nextcloud-file'\n      ? path\n      : generateWebLink({\n          slug: app,\n          cozyUrl,\n          // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment\n          subDomainType: client?.getInstanceOptions().subdomain,\n          // Inside notes, we need to add / at the end of /public/ or /preview/ to avoid 409 error\n          pathname:\n            type === 'public-note-same-instance'\n              ? joinPath(currentPathname, '')\n              : currentPathname,\n          searchParams: searchParams as unknown as unknown[],\n          hash: to.pathname\n        })\n\n  const openLink = useCallback(\n    (evt: React.MouseEvent<HTMLElement>) => {\n      if (\n        evt.ctrlKey ||\n        evt.metaKey ||\n        evt.shiftKey ||\n        shouldBeOpenedInNewTab\n      ) {\n        window.open(href, '_blank')\n      } else if (app === 'drive') {\n        navigate(to)\n      } else {\n        window.location.href = href\n      }\n    },\n    [app, href, navigate, to, shouldBeOpenedInNewTab]\n  )\n\n  return {\n    link: {\n      app,\n      href,\n      to,\n      openInNewTab: shouldBeOpenedInNewTab\n    },\n    openLink\n  }\n}\n\nexport { useFileLink }\n"
  },
  {
    "path": "src/modules/navigation/hooks/useSharedDriveLink.tsx",
    "content": "import { useCallback } from 'react'\nimport type { Path } from 'react-router-dom'\nimport { useResolvedPath, useNavigate } from 'react-router-dom'\n\nimport { useClient, generateWebLink } from 'cozy-client'\n\nimport {\n  SharedDrive,\n  getFolderIdFromSharing\n} from '@/modules/shareddrives/helpers'\n\nexport interface LinkResult {\n  app: string\n  href: string\n  to: Path\n  openInNewTab: boolean\n}\n\ninterface UseFileLinkResult {\n  link: LinkResult\n  openLink: (evt: React.MouseEvent<HTMLElement>) => void\n}\n\nconst useSharedDriveLink = (sharing: SharedDrive): UseFileLinkResult => {\n  const navigate = useNavigate()\n  const client = useClient()\n\n  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n  const cozyUrl = client?.getStackClient().uri as string\n\n  const app = 'drive'\n\n  const folderId = getFolderIdFromSharing(sharing)\n\n  if (!folderId) {\n    throw new Error('Missing folder id in shared drive')\n  }\n\n  /** Set shared drive path\n   * if user is owner of this shared drive, the path should be `folder/:folderId`\n   * otherwise the path should be `shareddrive/:driveId/:folderId`\n   */\n  const path = sharing.owner\n    ? `folder/${folderId}`\n    : `shareddrive/${sharing._id}/${folderId}`\n\n  const currentURL = new URL(window.location.href)\n  const currentPathname = currentURL.pathname\n  const currentSearchParams = currentURL.searchParams\n\n  const to = useResolvedPath(path, {\n    relative: 'route'\n  })\n\n  // we need to merge the searchParams of the current url and the new one created in computed path\n  // for example, to keep the sharecode in public context\n  const searchParams = new URLSearchParams({\n    ...Object.fromEntries(currentSearchParams.entries())\n  })\n\n  // nextcloud-file is a special case because Nextcloud are not in cozy ecosystem\n  // so we open their link directly\n  const href = generateWebLink({\n    slug: app,\n    cozyUrl,\n    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment\n    subDomainType: client?.getInstanceOptions().subdomain,\n    // Inside notes, we need to add / at the end of /public/ or /preview/ to avoid 409 error\n    pathname: currentPathname,\n    searchParams: searchParams as unknown as unknown[],\n    hash: to.pathname\n  })\n\n  const openLink = useCallback(\n    (evt: React.MouseEvent<HTMLElement>) => {\n      if (evt.ctrlKey || evt.metaKey || evt.shiftKey) {\n        window.open(href, '_blank')\n      } else if (app === 'drive') {\n        navigate(to)\n      } else {\n        window.location.href = href\n      }\n    },\n    [app, href, navigate, to]\n  )\n\n  return {\n    link: {\n      app,\n      href,\n      to,\n      openInNewTab: false\n    },\n    openLink\n  }\n}\n\nexport { useSharedDriveLink }\n"
  },
  {
    "path": "src/modules/nextcloud/components/NextcloudBanner.tsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\n\nimport Alert from 'cozy-ui/transpiled/react/Alert'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport NextcloudIcon from '@/assets/icons/icon-nextcloud.svg'\n\nconst NextcloudBanner = (): JSX.Element => {\n  const { t } = useI18n()\n  const { isMobile } = useBreakpoints()\n\n  return (\n    <div className={cx(!isMobile ? 'u-mb-1 u-mh-2' : 'u-mb-half')}>\n      <Alert\n        icon={<Icon icon={NextcloudIcon} size={16} />}\n        severity=\"secondary\"\n        square={isMobile}\n      >\n        <Typography variant=\"caption\">{t('NextcloudBanner.title')}</Typography>\n      </Alert>\n    </div>\n  )\n}\n\nexport { NextcloudBanner }\n"
  },
  {
    "path": "src/modules/nextcloud/components/NextcloudBreadcrumb.jsx",
    "content": "import React from 'react'\nimport { useLocation, useNavigate, useSearchParams } from 'react-router-dom'\n\nimport { useI18n } from 'twake-i18n'\n\nimport { ROOT_DIR_ID } from '@/constants/config'\nimport { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'\nimport { useNextcloudInfos } from '@/modules/nextcloud/hooks/useNextcloudInfos'\n\nconst NextcloudBreadcrumb = ({ sourceAccount, path }) => {\n  const [searchParams, setSearchParams] = useSearchParams()\n  const { pathname } = useLocation()\n  const navigate = useNavigate()\n  const { t } = useI18n()\n\n  const { rootFolderName } = useNextcloudInfos({ sourceAccount })\n\n  const rootPaths = [\n    {\n      name: t('breadcrumb.title_drive'),\n      id: ROOT_DIR_ID\n    },\n    {\n      name: t('breadcrumb.title_shared_drives'),\n      id: 'io.cozy.files.shared-drives-dir'\n    },\n    { name: rootFolderName, id: '/' }\n  ]\n\n  const splitPath = path.split('/').filter(Boolean)\n  const pathList = splitPath.map((folder, index) => {\n    if (folder === 'trash') {\n      return {\n        name: t('NextcloudBreadcrumb.trash'),\n        id: '/trash/'\n      }\n    }\n\n    let name = folder\n    // In Nextcloud, the path to the folder inside the trash ends with a number prefixed by a dot (.d1721754243)\n    // as we don't want to display this number in the breadcrumb, we remove it\n    if (path.startsWith('/trash')) {\n      const lastDotPosition = name.lastIndexOf('.')\n      name = folder.substring(0, lastDotPosition)\n    }\n\n    return {\n      name,\n      id: '/' + splitPath.slice(0, index + 1).join('/')\n    }\n  })\n\n  const handleBreadcrumbClick = item => {\n    if (\n      item.id === 'io.cozy.files.shared-drives-dir' ||\n      item.id === ROOT_DIR_ID\n    ) {\n      navigate(`/folder/${item.id}`)\n    } else if (pathname.endsWith('trash') && item.id === '/') {\n      navigate('..', { relative: 'path' })\n    } else {\n      searchParams.set('path', item.id)\n      setSearchParams(searchParams)\n    }\n  }\n\n  return (\n    <Breadcrumb\n      path={[...rootPaths, ...pathList]}\n      onBreadcrumbClick={handleBreadcrumbClick}\n    />\n  )\n}\n\nexport { NextcloudBreadcrumb }\n"
  },
  {
    "path": "src/modules/nextcloud/components/NextcloudDeleteConfirm.jsx",
    "content": "import React, { useState, useCallback } from 'react'\n\nimport { useClient } from 'cozy-client'\nimport { splitFilename } from 'cozy-client/dist/models/file'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ForbiddenIcon from 'cozy-ui/transpiled/react/Icons/Forbidden'\nimport RestoreIcon from 'cozy-ui/transpiled/react/Icons/Restore'\nimport List from 'cozy-ui/transpiled/react/List'\nimport ListItem from 'cozy-ui/transpiled/react/ListItem'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { getEntriesTypeTranslated } from '@/lib/entries'\nimport { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'\n\nconst NextcloudDeleteConfirm = ({ files, onClose }) => {\n  const { t } = useI18n()\n  const client = useClient()\n  const { showAlert } = useAlert()\n\n  const [isBusy, setBusy] = useState(false)\n\n  const onDelete = useCallback(async () => {\n    try {\n      setBusy(true)\n      await client\n        .collection('io.cozy.remote.nextcloud.files')\n        .destroyAll(files)\n      client.resetQuery(\n        computeNextcloudFolderQueryId({\n          sourceAccount: files[0].cozyMetadata.sourceAccount,\n          path: files[0].parentPath\n        })\n      )\n      client.resetQuery(\n        computeNextcloudFolderQueryId({\n          sourceAccount: files[0].cozyMetadata.sourceAccount,\n          path: '/trash/'\n        }) + '/trashed'\n      )\n    } catch (_e) {\n      showAlert({\n        message: t('NextcloudDeleteConfirm.error'),\n        severity: 'error'\n      })\n    } finally {\n      onClose()\n      setBusy(false)\n    }\n  }, [client, files, onClose, showAlert, t])\n\n  const entriesType = getEntriesTypeTranslated(t, files)\n  return (\n    <ConfirmDialog\n      open={true}\n      onClose={onClose}\n      title={t('NextcloudDeleteConfirm.title', {\n        filename: splitFilename(files[0]).filename,\n        smart_count: files.length,\n        type: entriesType\n      })}\n      content={\n        <List>\n          <ListItem gutters=\"disabled\" size=\"small\" ellipsis={false}>\n            <ListItemIcon>\n              <Icon icon={ForbiddenIcon} />\n            </ListItemIcon>\n            <ListItemText\n              primary={t(`NextcloudDeleteConfirm.trash`, {\n                smart_count: files.length\n              })}\n            />\n          </ListItem>\n          <ListItem gutters=\"disabled\" size=\"small\" ellipsis={false}>\n            <ListItemIcon>\n              <Icon icon={RestoreIcon} />\n            </ListItemIcon>\n            <ListItemText primary={t(`NextcloudDeleteConfirm.restore`)} />\n          </ListItem>\n        </List>\n      }\n      actions={\n        <>\n          <Buttons\n            variant=\"secondary\"\n            onClick={onClose}\n            label={t('NextcloudDeleteConfirm.cancel')}\n          />\n          <Buttons\n            busy={isBusy}\n            color=\"error\"\n            label={t('NextcloudDeleteConfirm.delete')}\n            onClick={onDelete}\n          />\n        </>\n      }\n    />\n  )\n}\n\nexport { NextcloudDeleteConfirm }\n"
  },
  {
    "path": "src/modules/nextcloud/components/NextcloudFolderBody.jsx",
    "content": "import React from 'react'\nimport { useSearchParams, useLocation, useNavigate } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { useI18n } from 'twake-i18n'\n\nimport { moveNextcloud } from './actions/moveNextcloud'\n\nimport { useClipboardContext } from '@/contexts/ClipboardProvider'\nimport { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'\nimport { hr } from '@/modules/actions'\nimport { duplicateTo } from '@/modules/actions/components/duplicateTo'\nimport { FolderBody } from '@/modules/folder/components/FolderBody'\nimport { deleteNextcloudFile } from '@/modules/nextcloud/components/actions/deleteNextcloudFile'\nimport { downloadNextcloudFile } from '@/modules/nextcloud/components/actions/downloadNextcloudFile'\nimport { openWithinNextcloud } from '@/modules/nextcloud/components/actions/openWithinNextcloud'\nimport { rename } from '@/modules/nextcloud/components/actions/rename'\nimport { shareNextcloudFile } from '@/modules/nextcloud/components/actions/shareNextcloudFile'\n\nconst NextcloudFolderBody = ({ path, queryResults }) => {\n  const [searchParams] = useSearchParams()\n  const client = useClient()\n  const { t } = useI18n()\n  const { pathname } = useLocation()\n  const navigate = useNavigate()\n  const { hasClipboardData } = useClipboardContext()\n\n  const allItems =\n    queryResults?.reduce((acc, result) => {\n      if (result.data) {\n        acc.push(...result.data)\n      }\n      return acc\n    }, []) || []\n\n  useKeyboardShortcuts({\n    onPaste: () => {},\n    canPaste: hasClipboardData,\n    client,\n    items: allItems,\n    sharingContext: null,\n    allowCopy: true,\n    isNextCloudFolder: true\n  })\n\n  const fileActions = makeActions(\n    [\n      shareNextcloudFile,\n      downloadNextcloudFile,\n      hr,\n      rename,\n      moveNextcloud,\n      duplicateTo,\n      openWithinNextcloud,\n      hr,\n      deleteNextcloudFile\n    ],\n    {\n      t,\n      client,\n      pathname,\n      navigate,\n      search: searchParams.toString()\n    }\n  )\n\n  return (\n    <FolderBody\n      folderId={path}\n      queryResults={queryResults}\n      actions={fileActions}\n      withFilePath={false}\n    />\n  )\n}\n\nexport { NextcloudFolderBody }\n"
  },
  {
    "path": "src/modules/nextcloud/components/NextcloudToolbar.jsx",
    "content": "import cx from 'classnames'\nimport React, { useState, useRef } from 'react'\n\nimport ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'\nimport {\n  makeActions,\n  divider\n} from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus'\nimport ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'\nimport { useI18n } from 'twake-i18n'\n\nimport { BarRightOnMobile } from '@/components/Bar'\nimport { MoreMenu } from '@/components/MoreMenu'\nimport { selectable } from '@/modules/actions/components/selectable'\nimport { addFolder } from '@/modules/nextcloud/components/actions/addFolder'\nimport { downloadNextcloudFolder } from '@/modules/nextcloud/components/actions/downloadNextcloudFolder'\nimport { openWithinNextcloud } from '@/modules/nextcloud/components/actions/openWithinNextcloud'\nimport { trash } from '@/modules/nextcloud/components/actions/trash'\nimport { upload } from '@/modules/nextcloud/components/actions/upload'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\n\nconst NextcloudToolbar = () => {\n  const { t } = useI18n()\n  const { showSelectionBar } = useSelectionContext()\n\n  /**\n   * TODO : Extract this logic to a component that can be reused for other toolbars\n   */\n  const [isAddMenuOpened, setAddMenuOpened] = useState(false)\n  const addButtonRef = useRef(null)\n  const toggleAddMenu = () => setAddMenuOpened(!isAddMenuOpened)\n  const closeAddMenu = () => setAddMenuOpened(false)\n  const addActions = makeActions([addFolder, divider, upload], { t })\n\n  const moreActions = makeActions(\n    [selectable, openWithinNextcloud, downloadNextcloudFolder, divider, trash],\n    {\n      t,\n      showSelectionBar\n    }\n  )\n\n  return (\n    <div\n      className={cx('u-flex', 'u-flex-items-center', 'u-ml-auto')}\n      role=\"toolbar\"\n    >\n      <Buttons\n        disabled\n        label={t('NextcloudToolbar.share')}\n        variant=\"secondary\"\n        startIcon={<Icon icon={ShareIcon} />}\n        className=\"u-mr-half\"\n      />\n      <div ref={addButtonRef}>\n        <Buttons\n          onClick={toggleAddMenu}\n          icon={PlusIcon}\n          label={t('toolbar.menu_add')}\n          startIcon={<Icon icon={PlusIcon} />}\n          aria-controls={isAddMenuOpened ? 'add-menu' : undefined}\n          aria-haspopup={true}\n          aria-expanded={isAddMenuOpened ? true : undefined}\n          className=\"u-mr-half\"\n        />\n      </div>\n      {isAddMenuOpened ? (\n        <ActionsMenu\n          open\n          ref={addButtonRef}\n          onClose={closeAddMenu}\n          actions={addActions}\n          docs={[]}\n          anchorOrigin={{\n            strategy: 'fixed',\n            vertical: 'bottom',\n            horizontal: 'right'\n          }}\n        />\n      ) : null}\n      <BarRightOnMobile>\n        <MoreMenu actions={moreActions} docs={[]} disabled={false} />\n      </BarRightOnMobile>\n    </div>\n  )\n}\n\nexport { NextcloudToolbar }\n"
  },
  {
    "path": "src/modules/nextcloud/components/NextcloudTrashFolderBody.tsx",
    "content": "import React, { FC } from 'react'\nimport { useSearchParams, useLocation, useNavigate } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport { UseQueryReturnValue } from 'cozy-client/types/types'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { FolderBody } from '@/modules/folder/components/FolderBody'\nimport { restoreNextcloudFile } from '@/modules/nextcloud/components/actions/restoreNextcloudFile'\nimport { destroy } from '@/modules/trash/components/actions/destroy'\n\ninterface NextcloudTrashFolderBodyProps {\n  path: string\n  queryResults: UseQueryReturnValue[]\n}\n\nconst NextcloudTrashFolderBody: FC<NextcloudTrashFolderBodyProps> = ({\n  path,\n  queryResults\n}) => {\n  const [searchParams] = useSearchParams()\n  const client = useClient()\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n  const { pathname } = useLocation()\n  const navigate = useNavigate()\n\n  const fileActions = makeActions([restoreNextcloudFile, destroy], {\n    t,\n    client,\n    showAlert,\n    pathname,\n    navigate,\n    search: searchParams.toString()\n  })\n\n  return (\n    <FolderBody\n      folderId={path}\n      queryResults={queryResults}\n      actions={fileActions}\n      withFilePath={false}\n      extraColumns={[]}\n    />\n  )\n}\n\nexport { NextcloudTrashFolderBody }\n"
  },
  {
    "path": "src/modules/nextcloud/components/actions/addFolder.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconFolder from 'cozy-ui/transpiled/react/Icons/FileTypeFolder'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nconst addFolder = ({ t }) => {\n  const label = t('toolbar.menu_new_folder')\n  const icon = IconFolder\n\n  return {\n    name: 'addFolder',\n    label,\n    icon,\n    action: () => {},\n    disabled: () => true,\n    Component: forwardRef(function AddFolder(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { addFolder }\n"
  },
  {
    "path": "src/modules/nextcloud/components/actions/deleteNextcloudFile.tsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport { Action } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { navigateToModalWithMultipleFile } from '@/modules/actions/helpers'\n\ninterface DeleteNextcloudFileProps {\n  t: (key: string) => string\n  pathname: string\n  navigate: (path: string) => void\n  search: string\n}\n\n/**\n * Deletes a Nextcloud file.\n *\n * @param t - The translation function.\n * @param pathname - The current pathname.\n * @param navigate - The navigation function.\n * @param search - The current search string.\n * @returns An actions menu item to delete a Nextcloud file\n */\nexport const deleteNextcloudFile = ({\n  t,\n  pathname,\n  navigate,\n  search\n}: DeleteNextcloudFileProps): Action => {\n  const label = t('SelectionBar.trash')\n  const icon = TrashIcon\n\n  return {\n    name: 'deleteNextcloudFile',\n    label,\n    icon,\n    action: (files): void => {\n      navigateToModalWithMultipleFile({\n        files,\n        pathname,\n        navigate,\n        path: 'delete',\n        search\n      })\n    },\n    Component: forwardRef(function DeleteNextcloudFile(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} color=\"var(--errorColor)\" />\n          </ListItemIcon>\n          <ListItemText\n            primary={label}\n            primaryTypographyProps={{ color: 'error' }}\n          />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n"
  },
  {
    "path": "src/modules/nextcloud/components/actions/downloadNextcloudFile.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport { isFile } from 'cozy-client/dist/models/file'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nexport const downloadNextcloudFile = ({ t, client }) => {\n  const label = t('SelectionBar.download')\n  const icon = DownloadIcon\n\n  return {\n    name: 'downloadNextcloudFile',\n    label,\n    icon,\n    displayCondition: docs => {\n      return docs.length === 1\n    },\n    action: docs => {\n      return client\n        .collection('io.cozy.remote.nextcloud.files')\n        .download(docs[0])\n    },\n    disabled: docs => docs.some(doc => !isFile(doc)),\n    Component: forwardRef(function DownloadNextcloudFile(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n"
  },
  {
    "path": "src/modules/nextcloud/components/actions/downloadNextcloudFolder.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nconst downloadNextcloudFolder = ({ t }) => {\n  const label = t('toolbar.menu_download_folder')\n  const icon = DownloadIcon\n\n  return {\n    name: 'downloadNextcloudFolder',\n    label,\n    icon,\n    action: () => {},\n    disabled: () => true,\n    Component: forwardRef(function DownloadNextcloudFolder(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { downloadNextcloudFolder }\n"
  },
  {
    "path": "src/modules/nextcloud/components/actions/duplicateNextcloudFile.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport { isFile } from 'cozy-client/dist/models/file'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport MultiFilesIcon from 'cozy-ui/transpiled/react/Icons/MultiFiles'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nconst duplicateNextcloudFile = ({ t }) => {\n  const label = t('SelectionBar.duplicate')\n  const icon = MultiFilesIcon\n\n  return {\n    name: 'duplicateNextcloudFile',\n    label,\n    icon,\n    displayCondition: selection => {\n      return selection.length === 1 && isFile(selection[0])\n    },\n    action: () => {},\n    disabled: () => true,\n    Component: forwardRef(function Duplicate(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { duplicateNextcloudFile }\n"
  },
  {
    "path": "src/modules/nextcloud/components/actions/moveNextcloud.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport MovetoIcon from 'cozy-ui/transpiled/react/Icons/Moveto'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { navigateToModalWithMultipleFile } from '@/modules/actions/helpers'\n\nconst moveNextcloud = ({ t, pathname, navigate, search }) => {\n  const label = t('SelectionBar.moveto')\n  const icon = MovetoIcon\n\n  return {\n    name: 'moveNextcloud',\n    label,\n    icon,\n    displayCondition: docs => docs.length > 0,\n    action: files => {\n      navigateToModalWithMultipleFile({\n        files,\n        pathname,\n        navigate,\n        path: 'move',\n        search\n      })\n    },\n    disabled: docs => docs.some(doc => doc.type === 'directory'),\n    Component: forwardRef(function MoveNextcloud(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { moveNextcloud }\n"
  },
  {
    "path": "src/modules/nextcloud/components/actions/openWithinNextcloud.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport LinkOutIcon from 'cozy-ui/transpiled/react/Icons/LinkOut'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nexport const openWithinNextcloud = ({ t }) => {\n  const label = t('SelectionBar.openWithinNextcloud')\n  const icon = LinkOutIcon\n\n  return {\n    name: 'openWithinNextcloud',\n    label,\n    icon,\n    displayCondition: docs => docs.length === 1,\n    action: docs => {\n      window.open(docs[0].links.self, '_blank')\n    },\n    Component: forwardRef(function OpenWithinNextcloud(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon></ListItemIcon>\n          <ListItemText primary={label} />\n          <ListItemIcon>\n            <Icon icon={LinkOutIcon} />\n          </ListItemIcon>\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n"
  },
  {
    "path": "src/modules/nextcloud/components/actions/rename.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport RenameIcon from 'cozy-ui/transpiled/react/Icons/Rename'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nconst rename = ({ t }) => {\n  const label = t('SelectionBar.rename')\n  const icon = RenameIcon\n\n  return {\n    name: 'rename',\n    label,\n    icon,\n    displayCondition: docs => docs.length === 1,\n    action: () => {},\n    disabled: () => true,\n    Component: forwardRef(function Rename(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { rename }\n"
  },
  {
    "path": "src/modules/nextcloud/components/actions/restoreNextcloudFile.tsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport CozyClient from 'cozy-client/types/CozyClient'\nimport { NextcloudFile } from 'cozy-client/types/types'\nimport { Action } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport RestoreIcon from 'cozy-ui/transpiled/react/Icons/Restore'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { getParentPath } from '@/lib/path'\nimport { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'\n\ninterface NextcloudFilesCollection {\n  restore: (file: NextcloudFile) => Promise<void>\n}\n\ninterface RestoreNextcloudFileProps {\n  t: (key: string) => string\n  client: CozyClient\n  showAlert: import('cozy-ui/transpiled/react/providers/Alert').showAlertFunction\n}\n\nexport const restoreNextcloudFile = ({\n  t,\n  client,\n  showAlert\n}: RestoreNextcloudFileProps): Action<NextcloudFile> => {\n  const label = t('RestoreNextcloudFile.label')\n  const icon = RestoreIcon\n\n  return {\n    name: 'restoreNextcloudFile',\n    label,\n    icon,\n    displayCondition: (files): boolean => files.length > 0,\n    action: async (files): Promise<void> => {\n      const sourceAccount = files[0].cozyMetadata.sourceAccount\n      const parentPath = files[0].parentPath\n\n      try {\n        for (const file of files) {\n          const collection = client.collection(\n            'io.cozy.remote.nextcloud.files'\n          ) as unknown as NextcloudFilesCollection\n          await collection.restore(file)\n        }\n\n        const restorePaths = files\n          .map(file =>\n            file.restore_path ? getParentPath(file.restore_path) : undefined\n          )\n          .filter(Boolean)\n\n        const uniqueRestorePaths = Array.from(new Set(restorePaths))\n\n        const resetResults = await Promise.all(\n          uniqueRestorePaths.map(restorePath => {\n            const queryId = computeNextcloudFolderQueryId({\n              sourceAccount,\n              path: restorePath\n            })\n            return client.resetQuery(queryId)\n          })\n        )\n\n        // If the query for the folder containing the restored files does not exist,\n        // we need to reset the query of the current folder to refresh the view.\n        // Since the current folder is the trash folder, its queryId ends with '/trashed'.\n        if (resetResults.some(query => query === null)) {\n          const queryId =\n            computeNextcloudFolderQueryId({\n              sourceAccount,\n              path: parentPath\n            }) + '/trashed'\n          await client.resetQuery(queryId)\n        }\n\n        showAlert({\n          message: t('RestoreNextcloudFile.success'),\n          severity: 'success'\n        })\n      } catch (_error) {\n        showAlert({\n          message: t('RestoreNextcloudFile.error'),\n          severity: 'error'\n        })\n      }\n    },\n    Component: forwardRef(function RestoreNextcloudFile(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n"
  },
  {
    "path": "src/modules/nextcloud/components/actions/shareNextcloudFile.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport LinkOutIcon from 'cozy-ui/transpiled/react/Icons/LinkOut'\nimport ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nconst shareNextcloudFile = ({ t }) => {\n  const label = t('toolbar.share')\n  const icon = ShareIcon\n\n  return {\n    name: 'share',\n    label,\n    icon,\n    displayCondition: docs => docs.length === 1,\n    action: docs => {\n      window.open(docs[0].links.self, '_blank')\n    },\n    Component: forwardRef(function Share(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n          <ListItemIcon>\n            <Icon icon={LinkOutIcon} />\n          </ListItemIcon>\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { shareNextcloudFile }\n"
  },
  {
    "path": "src/modules/nextcloud/components/actions/trash.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nconst trash = ({ t }) => {\n  const label = t('SelectionBar.trash')\n  const icon = TrashIcon\n\n  return {\n    name: 'trash',\n    label,\n    icon,\n    action: () => {},\n    disabled: () => true,\n    Component: forwardRef(function DeleteNextcloudFile(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} color=\"var(--errorColor)\" />\n          </ListItemIcon>\n          <ListItemText\n            primary={label}\n            primaryTypographyProps={{ color: 'error' }}\n          />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { trash }\n"
  },
  {
    "path": "src/modules/nextcloud/components/actions/upload.jsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport UploadIcon from 'cozy-ui/transpiled/react/Icons/Upload'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nconst upload = ({ t }) => {\n  const label = t('toolbar.menu_upload')\n  const icon = UploadIcon\n\n  return {\n    name: 'upload',\n    label,\n    icon,\n    action: () => {},\n    disabled: () => true,\n    Component: forwardRef(function Upload(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n\nexport { upload }\n"
  },
  {
    "path": "src/modules/nextcloud/helpers.ts",
    "content": "import { isShortcut } from 'cozy-client/dist/models/file'\nimport type { IOCozyFile, NextcloudFile } from 'cozy-client/types/types'\nimport flag from 'cozy-flags'\n\nimport type { File, FolderPickerEntry } from '@/components/FolderPicker/types'\n\nexport const computeNextcloudFolderQueryId = ({\n  sourceAccount,\n  path\n}: {\n  sourceAccount: string\n  path: string\n}): string => {\n  return `io.cozy.remote.nextcloud.files/sourceAccount/${sourceAccount}/path${path}`\n}\n\n/**\n * Checks if the given file is a Nextcloud shortcut.\n *\n * @param file - The file object to check.\n * @returns - Returns true if the file is a Nextcloud shortcut, false otherwise.\n */\nexport const isNextcloudShortcut = (file: IOCozyFile): boolean => {\n  return (\n    isShortcut(file) &&\n    file.cozyMetadata?.createdByApp === 'nextcloud' &&\n    !flag('drive.hide-nextcloud-dev')\n  )\n}\n\nexport const isNextcloudFile = (\n  file: File | FolderPickerEntry\n): file is NextcloudFile => {\n  return file._type === 'io.cozy.remote.nextcloud.files'\n}\n"
  },
  {
    "path": "src/modules/nextcloud/hooks/useNextcloudCurrentFolder.tsx",
    "content": "import { useParams } from 'react-router-dom'\n\nimport { NextcloudFile, UseQueryReturnValue } from 'cozy-client/types/types'\n\nimport { computeNextcloudRootFolder } from '@/components/FolderPicker/helpers'\nimport { getParentPath } from '@/lib/path'\nimport { hasDataLoaded } from '@/lib/queries'\nimport { useNextcloudFolder } from '@/modules/nextcloud/hooks/useNextcloudFolder'\nimport { useNextcloudInfos } from '@/modules/nextcloud/hooks/useNextcloudInfos'\nimport { useNextcloudPath } from '@/modules/nextcloud/hooks/useNextcloudPath'\n\n/**\n * Nextcloud don't have route to get parent folder\n * so we need to fetch the content of his parent folder to get current folder data\n */\nconst useNextcloudCurrentFolder = (): NextcloudFile | undefined => {\n  const { sourceAccount } = useParams()\n  const path = useNextcloudPath()\n\n  const { instanceName } = useNextcloudInfos({ sourceAccount })\n  const { nextcloudResult } = useNextcloudFolder({\n    sourceAccount,\n    path: getParentPath(path)\n  }) as {\n    nextcloudResult: {\n      data?: NextcloudFile[] | null\n    }\n  }\n\n  if (path === '/' && sourceAccount) {\n    return computeNextcloudRootFolder({\n      sourceAccount,\n      instanceName\n    })\n  }\n\n  if (hasDataLoaded(nextcloudResult as UseQueryReturnValue)) {\n    return (nextcloudResult.data ?? []).find(file => file.path === path)\n  }\n\n  return undefined\n}\n\nexport { useNextcloudCurrentFolder }\n"
  },
  {
    "path": "src/modules/nextcloud/hooks/useNextcloudEntries.tsx",
    "content": "import { useLocation, useParams } from 'react-router-dom'\n\nimport { NextcloudFile } from 'cozy-client/types/types'\n\nimport { hasDataLoaded } from '@/lib/queries'\nimport { useNextcloudFolder } from '@/modules/nextcloud/hooks/useNextcloudFolder'\nimport { useNextcloudPath } from '@/modules/nextcloud/hooks/useNextcloudPath'\n\ninterface useNextcloudEntriesReturn {\n  isLoading: boolean\n  entries?: NextcloudFile[]\n  hasEntries: boolean\n}\n\nconst useNextcloudEntries = ({\n  insideTrash = false\n} = {}): useNextcloudEntriesReturn => {\n  const { state } = useLocation() as {\n    state?: { fileIds?: string[] }\n  }\n\n  const { sourceAccount } = useParams()\n  const path = useNextcloudPath({\n    insideTrash\n  })\n\n  const { nextcloudResult } = useNextcloudFolder({\n    sourceAccount,\n    path,\n    insideTrash\n  })\n\n  if (!state?.fileIds) {\n    return {\n      isLoading: false,\n      hasEntries: false\n    }\n  }\n\n  if (hasDataLoaded(nextcloudResult)) {\n    const entries = nextcloudResult.data.filter(({ _id }) =>\n      state.fileIds.includes(_id)\n    )\n    return {\n      isLoading: false,\n      hasEntries: entries.length > 0,\n      entries\n    }\n  }\n\n  return {\n    isLoading: true,\n    hasEntries: true\n  }\n}\n\nexport { useNextcloudEntries }\n"
  },
  {
    "path": "src/modules/nextcloud/hooks/useNextcloudFolder.tsx",
    "content": "import { useQuery } from 'cozy-client'\nimport { NextcloudFile } from 'cozy-client/types/types'\n\nimport {\n  buildNextcloudFolderQuery,\n  buildNextcloudTrashFolderQuery,\n  QueryConfig\n} from '@/queries'\n\ninterface NextcloudFolderProps {\n  sourceAccount?: string\n  path: string\n  insideTrash: boolean\n}\n\ninterface NextcloudFolderReturn {\n  nextcloudQuery: QueryConfig\n  nextcloudResult: {\n    data?: NextcloudFile[] | null\n  }\n}\n\nconst useNextcloudFolder = ({\n  sourceAccount,\n  path,\n  insideTrash = false\n}: NextcloudFolderProps): NextcloudFolderReturn => {\n  const queryBuilder = insideTrash\n    ? buildNextcloudTrashFolderQuery\n    : buildNextcloudFolderQuery\n\n  const nextcloudQuery = queryBuilder({\n    sourceAccount,\n    path\n  })\n  const nextcloudResult = useQuery(\n    nextcloudQuery.definition,\n    nextcloudQuery.options\n  ) as NextcloudFolderReturn['nextcloudResult']\n\n  return {\n    nextcloudQuery,\n    nextcloudResult\n  }\n}\n\nexport { useNextcloudFolder }\n"
  },
  {
    "path": "src/modules/nextcloud/hooks/useNextcloudInfos.jsx",
    "content": "import { hasQueryBeenLoaded, useQuery } from 'cozy-client'\n\nimport { buildNextcloudShortcutQuery } from '@/queries'\n\n/**\n * @typedef {Object} NextcloudInfos\n * @property {boolean} isLoading -  Whether the data is still loading\n * @property {string} [instanceName] - The name of the Nextcloud instance\n * @property {string} [instanceUrl] - The URL of the Nextcloud instance\n * @property {string} [rootFolderName] - The name of the root folder\n */\n\n/**\n * Fetches the Nextcloud instance name and URL\n *\n * @param {Object} params\n * @param {string} [params.sourceAccount] - The source account\n * @returns {NextcloudInfos}\n */\nconst useNextcloudInfos = ({ sourceAccount }) => {\n  const nextcloudShortcutsQuery = buildNextcloudShortcutQuery({\n    sourceAccount\n  })\n  const nextcloudShortcutsResult = useQuery(\n    nextcloudShortcutsQuery.definition,\n    nextcloudShortcutsQuery.options\n  )\n\n  if (\n    hasQueryBeenLoaded(nextcloudShortcutsResult) &&\n    nextcloudShortcutsResult.data.length > 0 &&\n    nextcloudShortcutsResult.data[0].metadata\n  ) {\n    const instanceName = nextcloudShortcutsResult.data[0].metadata.instanceName\n    return {\n      isLoading: false,\n      instanceName: nextcloudShortcutsResult.data[0].metadata.instanceName,\n      instanceUrl: nextcloudShortcutsResult.data[0].metadata.fileIdAttributes,\n      rootFolderName: `${instanceName} (Nextcloud)`\n    }\n  }\n\n  return {\n    isLoading: true\n  }\n}\n\nexport { useNextcloudInfos }\n"
  },
  {
    "path": "src/modules/nextcloud/hooks/useNextcloudPath.jsx",
    "content": "import { useSearchParams } from 'react-router-dom'\n\nconst useNextcloudPath = ({ insideTrash = false } = {}) => {\n  const [searchParams] = useSearchParams()\n  const defaultPath = insideTrash ? '/trash/' : '/'\n  return searchParams.get('path') ?? defaultPath\n}\n\nexport { useNextcloudPath }\n"
  },
  {
    "path": "src/modules/paste/index.js",
    "content": "import {\n  isFile,\n  copy,\n  move,\n  moveRelateToSharedDrive\n} from 'cozy-client/dist/models/file'\n\nimport { resolveNameConflictsForCut } from './utils'\n\nimport { ROOT_DIR_ID, NEXTCLOUD_FILE_ID } from '@/constants/config'\nimport { DOCTYPE_FILES } from '@/lib/doctypes'\nimport logger from '@/lib/logger'\nimport { joinPath } from '@/lib/path'\nimport { hasOneOfEntriesShared } from '@/modules/move/helpers'\nimport { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'\n\n/**\n * Executes move or copy operations for shared drive files/folders.\n * Handles the specific API calls required for shared drive operations.\n *\n * @param {CozyClient} client - The cozy client instance\n * @param {import('@/components/FolderPicker/types').File} entry - The file or folder to move/copy\n * @param {import('@/components/FolderPicker/types').File} sourceDirectory - The source directory containing the entry\n * @param {import('@/components/FolderPicker/types').File} destDirectory - The destination directory\n * @param {string} operation - The operation type ('move' or 'copy')\n * @returns {Promise<Object>} The result of the shared drive operation\n */\nconst executeSharedDriveMoveOrCopy = async (\n  client,\n  entry,\n  sourceDirectory,\n  destDirectory,\n  operation\n) => {\n  return await moveRelateToSharedDrive(\n    client,\n    {\n      instance: entry.driveId\n        ? sourceDirectory.attributes?.cozyMetadata?.createdOn\n        : '',\n      file_id: isFile(entry) ? entry._id : '',\n      dir_id: !isFile(entry) ? entry._id : '',\n      sharing_id: entry.driveId\n    },\n    {\n      instance: destDirectory.driveId\n        ? destDirectory.cozyMetadata?.createdOn\n        : '',\n      sharing_id: destDirectory.driveId,\n      dir_id: destDirectory._id\n    },\n    operation === 'copy'\n  )\n}\n\n/**\n * Executes a move operation for files or folders.\n * Automatically detects if it's a shared drive operation and uses the appropriate API.\n *\n * @param {CozyClient} client - The cozy client instance\n * @param {import('@/components/FolderPicker/types').File} entry - The file or folder to move\n * @param {import('@/components/FolderPicker/types').File} sourceDirectory - The source directory containing the entry\n * @param {import('@/components/FolderPicker/types').File} destDirectory - The destination directory\n * @param {boolean} [force=false] - Whether to force the move operation\n * @returns {Promise<Object>} The result of the move operation\n */\nexport const executeMove = async (\n  client,\n  entry,\n  sourceDirectory,\n  destDirectory,\n  force = false\n) => {\n  const isSharedDriveOperation = entry.driveId || destDirectory.driveId\n  if (isSharedDriveOperation) {\n    return await executeSharedDriveMoveOrCopy(\n      client,\n      entry,\n      sourceDirectory,\n      destDirectory\n    )\n  }\n  return await move(client, entry, destDirectory, {\n    force\n  })\n}\n\n/**\n * Handles paste operations (copy or cut) for multiple files/folders.\n * Processes each file individually and handles validation, conflicts, and sharing permissions.\n *\n * @param {CozyClient} client - The cozy client instance\n * @param {Array<import('@/components/FolderPicker/types').File>} files - Array of files/folders to paste\n * @param {string | null} operation - The paste operation ('copy' or 'cut')\n * @param {import('@/components/FolderPicker/types').File} sourceDirectory - The source directory containing the files\n * @param {import('@/components/FolderPicker/types').File} targetFolder - The target folder for the paste operation\n * @param {Object} [options={}] - Additional options\n * @param {Function} [options.showAlert] - Function to show user alerts\n * @param {Function} [options.t] - Translation function\n * @param {unknown} [options.sharingContext] - Sharing context for validation\n * @param {Function} [options.showMoveValidationModal] - Function to show move validation modals\n * @param {boolean} [options.isPublic] - Whether the target folder is in public view\n * @returns {Promise<Array<{ success: boolean; file: import('@/components/FolderPicker/types').File; error?: Error; operation: string }>>} Array of operation results with success/failure status\n */\nexport const handlePasteOperation = async (\n  client,\n  files,\n  operation,\n  sourceDirectory,\n  targetFolder,\n  options = {}\n) => {\n  const { showAlert, t, sharingContext, isPublic } = options\n  const results = []\n\n  // For cut operations, resolve name conflicts first\n  let processedFiles = files\n  if (operation === 'cut') {\n    processedFiles = await resolveNameConflictsForCut(\n      client,\n      files,\n      targetFolder,\n      isPublic\n    )\n  }\n\n  const isCopyOperation = operation === 'copy'\n  const isCutOperation = operation === 'cut'\n\n  let hasValidatedMove = false\n\n  for (const file of processedFiles) {\n    try {\n      if (isCopyOperation) {\n        if (!isFile(file)) continue\n\n        const result = await handleDuplicateWithValidation(\n          client,\n          file,\n          targetFolder,\n          { showAlert, t }\n        )\n        results.push({ success: true, file: result, operation: 'copy' })\n      } else if (isCutOperation) {\n        const shouldValidateMove = !hasValidatedMove\n        if (shouldValidateMove) {\n          hasValidatedMove = true\n        }\n\n        const result = await handleMoveWithValidation(\n          client,\n          file,\n          sourceDirectory,\n          targetFolder,\n          {\n            sharingContext: shouldValidateMove ? sharingContext : null,\n            showMoveValidationModal: shouldValidateMove\n              ? options.showMoveValidationModal\n              : null\n          }\n        )\n        results.push({ success: true, file: result, operation: 'move' })\n      }\n    } catch (error) {\n      results.push({ success: false, file, error, operation })\n    }\n  }\n\n  return results\n}\n\n/**\n * Handles file duplication (copy operation) with validation and user feedback.\n * Shows success alerts and handles Nextcloud query refreshing.\n *\n * @param {CozyClient} client - The cozy client instance\n * @param {import('@/components/FolderPicker/types').File} file - The file to duplicate\n * @param {import('@/components/FolderPicker/types').File} targetFolder - The target folder for duplication\n * @param {Object} [options={}] - Additional options\n * @param {Function} [options.showAlert] - Function to show user alerts\n * @param {Function} [options.t] - Translation function\n * @returns {Promise<Object>} The duplicated file object\n */\nconst handleDuplicateWithValidation = async (\n  client,\n  file,\n  targetFolder,\n  options = {}\n) => {\n  const { showAlert, t } = options\n\n  const result = await copy(client, file, targetFolder)\n\n  const isCopyingInsideNextcloud = targetFolder._type === NEXTCLOUD_FILE_ID\n  if (isCopyingInsideNextcloud) {\n    refreshNextcloudQueries(client, targetFolder)\n  }\n\n  if (showAlert && t) {\n    showAlert({\n      message: t('DuplicateModal.success', {\n        smart_count: 1,\n        fileName: file.name,\n        destinationName:\n          targetFolder._id === ROOT_DIR_ID\n            ? t('breadcrumb.title_drive')\n            : targetFolder.name\n      }),\n      severity: 'success'\n    })\n  }\n\n  return result\n}\n\n/**\n * Creates a promisified move operation with user confirmation modal.\n *\n * @param {Function} showMoveValidationModal - Function to show validation modal\n * @param {string} modalType - Type of modal to show\n * @param {import('@/components/FolderPicker/types').File} file - File to move\n * @param {import('@/components/FolderPicker/types').File} targetFolder - Target folder\n * @param {CozyClient} client - Cozy client instance\n * @param {import('@/components/FolderPicker/types').File} sourceDirectory - Source directory\n * @returns {Promise<Object>} Promise that resolves with move result\n */\nconst createMoveWithConfirmation = (\n  showMoveValidationModal,\n  modalType,\n  file,\n  targetFolder,\n  client,\n  sourceDirectory\n) => {\n  return new Promise((resolve, reject) => {\n    const executeConfirmedMove = async () => {\n      try {\n        const result = await executeMove(\n          client,\n          file,\n          sourceDirectory,\n          targetFolder,\n          true\n        )\n        resolve(result)\n      } catch (error) {\n        reject(error)\n      }\n    }\n\n    const cancelMove = () => reject(new Error('Move cancelled by user'))\n\n    showMoveValidationModal(\n      modalType,\n      file,\n      targetFolder,\n      executeConfirmedMove,\n      cancelMove\n    )\n  })\n}\n\n/**\n * Determines the sharing context for a file and target folder.\n *\n * @param {import('@/components/FolderPicker/types').File} file - File to analyze\n * @param {import('@/components/FolderPicker/types').File} targetFolder - Target folder\n * @param {Object} sharingContext - Sharing context with helper functions\n * @returns {Object} Sharing analysis result\n */\nconst analyzeSharingContext = (file, targetFolder, sharingContext) => {\n  const { getSharedParentPath, hasSharedParent, byDocId } = sharingContext\n\n  const sharedParentPath = file.path ? getSharedParentPath(file.path) : ''\n  const targetPath = joinPath(targetFolder.path, file.name)\n\n  const areMovedFilesShared = hasOneOfEntriesShared([file], byDocId)\n  const isOriginParentShared =\n    hasSharedParent(file.path || '') || !!file.driveId\n  const isTargetShared =\n    hasSharedParent(targetPath || '') ||\n    (!!targetFolder.driveId && targetFolder.driveId !== file.driveId)\n  const isInsideSameSharedFolder =\n    (sharedParentPath && targetPath.startsWith(sharedParentPath)) ||\n    (!!file.driveId &&\n      !!targetFolder.driveId &&\n      file.driveId === targetFolder.driveId)\n\n  return {\n    areMovedFilesShared,\n    isOriginParentShared,\n    isTargetShared,\n    isInsideSameSharedFolder\n  }\n}\n\n/**\n * Handles sharing validation and shows appropriate modals if needed.\n *\n * @param {import('@/components/FolderPicker/types').File} file - File to move\n * @param {import('@/components/FolderPicker/types').File} targetFolder - Target folder\n * @param {Object} sharingContext - Sharing context\n * @param {Function} showMoveValidationModal - Modal function\n * @param {CozyClient} client - Cozy client\n * @param {import('@/components/FolderPicker/types').File} sourceDirectory - Source directory\n * @returns {Promise<Object>|null} Move result or null if no validation needed\n */\nconst handleSharingValidation = async (\n  file,\n  targetFolder,\n  sharingContext,\n  showMoveValidationModal,\n  client,\n  sourceDirectory\n) => {\n  const { getSharedParentPath, hasSharedParent, byDocId } = sharingContext\n\n  const needsSharingValidation =\n    (getSharedParentPath && hasSharedParent && byDocId) ||\n    !!file.driveId ||\n    !!targetFolder.driveId\n\n  if (!needsSharingValidation) return null\n\n  try {\n    const sharingAnalysis = analyzeSharingContext(\n      file,\n      targetFolder,\n      sharingContext\n    )\n    const {\n      areMovedFilesShared,\n      isOriginParentShared,\n      isTargetShared,\n      isInsideSameSharedFolder\n    } = sharingAnalysis\n\n    if (isInsideSameSharedFolder) return null\n\n    if (isOriginParentShared && !isTargetShared) {\n      return createMoveWithConfirmation(\n        showMoveValidationModal,\n        'moveOutside',\n        file,\n        targetFolder,\n        client,\n        sourceDirectory\n      )\n    }\n\n    if (!areMovedFilesShared && isTargetShared) {\n      return createMoveWithConfirmation(\n        showMoveValidationModal,\n        'moveInside',\n        file,\n        targetFolder,\n        client,\n        sourceDirectory\n      )\n    }\n\n    if (areMovedFilesShared && isTargetShared) {\n      return createMoveWithConfirmation(\n        showMoveValidationModal,\n        'moveSharedInside',\n        file,\n        targetFolder,\n        client,\n        sourceDirectory\n      )\n    }\n  } catch (error) {\n    logger.error('Failed to validate sharing context:', error)\n  }\n\n  return null\n}\n\n/**\n * Handles file renaming if needed.\n *\n * @param {CozyClient} client - Cozy client instance\n * @param {import('@/components/FolderPicker/types').File} file - File to rename\n */\nconst handleFileRename = async (client, file) => {\n  if (!file.needsRename) return\n\n  await client.collection(DOCTYPE_FILES).update({\n    ...file,\n    name: file.uniqueName,\n    _rev: file._rev || file.meta.rev\n  })\n}\n\n/**\n * Handles Nextcloud query refresh after move operations.\n *\n * @param {CozyClient} client - Cozy client instance\n * @param {import('@/components/FolderPicker/types').File} file - Moved file\n * @param {import('@/components/FolderPicker/types').File} targetFolder - Target folder\n */\nconst handleNextcloudRefresh = (client, file, targetFolder) => {\n  const isMovingInsideNextcloud = targetFolder._type === NEXTCLOUD_FILE_ID\n  const isMovingOutsideNextcloud =\n    !isMovingInsideNextcloud && file._type === NEXTCLOUD_FILE_ID\n\n  if (isMovingInsideNextcloud || isMovingOutsideNextcloud) {\n    refreshNextcloudQueries(client, targetFolder, file, {\n      isMovingInsideNextcloud,\n      isMovingOutsideNextcloud\n    })\n  }\n}\n\n/**\n * Handles file/folder move operations with comprehensive sharing validation.\n * Checks for shared folder boundaries and shows appropriate validation modals.\n * Handles name conflicts and Nextcloud integration.\n *\n * @param {CozyClient} client - The cozy client instance\n * @param {import('@/components/FolderPicker/types').File} file - The file or folder to move\n * @param {import('@/components/FolderPicker/types').File} sourceDirectory - The source directory containing the file\n * @param {import('@/components/FolderPicker/types').File} targetFolder - The target folder for the move\n * @param {Object} [options={}] - Additional options\n * @param {Object} [options.sharingContext] - Sharing context for validation\n * @param {Function} [options.showMoveValidationModal] - Function to show move validation modals\n * @returns {Promise<Object>} The moved file/folder object\n */\nconst handleMoveWithValidation = async (\n  client,\n  file,\n  sourceDirectory,\n  targetFolder,\n  options = {}\n) => {\n  const { sharingContext, showMoveValidationModal } = options\n\n  const canValidateSharing =\n    sharingContext &&\n    (file.path || file.driveId) &&\n    targetFolder.path &&\n    showMoveValidationModal\n\n  if (canValidateSharing) {\n    const validationResult = await handleSharingValidation(\n      file,\n      targetFolder,\n      sharingContext,\n      showMoveValidationModal,\n      client,\n      sourceDirectory\n    )\n\n    if (validationResult !== null) {\n      return validationResult\n    }\n  }\n\n  await handleFileRename(client, file)\n\n  const result = await executeMove(client, file, sourceDirectory, targetFolder)\n\n  handleNextcloudRefresh(client, file, targetFolder)\n\n  return result\n}\n\n/**\n * Refreshes Nextcloud queries after move/copy operations.\n *\n * @param {CozyClient} client - The cozy client instance\n * @param {import('@/components/FolderPicker/types').File} targetFolder - The target folder of the operation\n * @param {import('@/components/FolderPicker/types').File | null} [sourceFile=null] - The source file (for move operations)\n * @param {Object} [options={}] - Additional options\n * @param {boolean} [options.isMovingInsideNextcloud=false] - Whether moving into Nextcloud\n * @param {boolean} [options.isMovingOutsideNextcloud=false] - Whether moving out of Nextcloud\n */\nconst refreshNextcloudQueries = (\n  client,\n  targetFolder,\n  sourceFile = null,\n  options = {}\n) => {\n  const { isMovingInsideNextcloud = false, isMovingOutsideNextcloud = false } =\n    options\n\n  if (isMovingInsideNextcloud || targetFolder._type === NEXTCLOUD_FILE_ID) {\n    const queryId = computeNextcloudFolderQueryId({\n      sourceAccount: targetFolder.cozyMetadata?.sourceAccount,\n      path: targetFolder.path\n    })\n    client?.resetQuery(queryId)\n  }\n\n  if (isMovingOutsideNextcloud && sourceFile) {\n    const queryId = computeNextcloudFolderQueryId({\n      sourceAccount: sourceFile.cozyMetadata?.sourceAccount,\n      path: sourceFile.path\n    })\n    client?.resetQuery(queryId)\n  }\n}\n"
  },
  {
    "path": "src/modules/paste/index.spec.js",
    "content": "import { handlePasteOperation } from './index'\n\n// Mock dependencies\njest.mock('cozy-client/dist/models/file', () => ({\n  isFile: jest.fn(),\n  copy: jest.fn(),\n  move: jest.fn()\n}))\n\njest.mock('./utils', () => ({\n  resolveNameConflictsForCut: jest.fn()\n}))\n\njest.mock('../move/helpers', () => ({\n  hasOneOfEntriesShared: jest.fn()\n}))\n\njest.mock('../../lib/logger', () => ({\n  error: jest.fn(),\n  info: jest.fn()\n}))\n\nconst { isFile, copy, move } = require('cozy-client/dist/models/file')\n\nconst { resolveNameConflictsForCut } = require('./utils')\nconst { hasOneOfEntriesShared } = require('../move/helpers')\n\ndescribe('handlePasteOperation', () => {\n  let mockClient, mockFiles, mockTargetFolder, mockSourceDirectory, mockOptions\n\n  beforeEach(() => {\n    mockClient = {\n      save: jest.fn(),\n      query: jest.fn(),\n      collection: jest.fn(() => ({\n        updateFile: jest.fn(),\n        update: jest.fn().mockResolvedValue({ data: { _id: 'updated-file' } })\n      }))\n    }\n\n    mockFiles = [\n      {\n        _id: 'file1',\n        name: 'test1.txt',\n        type: 'file',\n        attributes: { name: 'test1.txt' }\n      },\n      {\n        _id: 'file2',\n        name: 'test2.txt',\n        type: 'file',\n        attributes: { name: 'test2.txt' }\n      }\n    ]\n\n    mockTargetFolder = {\n      _id: 'target-folder',\n      name: 'Target Folder',\n      path: '/Target Folder'\n    }\n\n    mockSourceDirectory = {\n      _id: 'source-folder',\n      name: 'Source Folder',\n      path: '/Source Folder'\n    }\n\n    mockOptions = {\n      showAlert: jest.fn(),\n      t: jest.fn(key => key),\n      sharingContext: {}\n    }\n\n    // Default mocks\n    isFile.mockReturnValue(true)\n    copy.mockResolvedValue({ data: { _id: 'copied-file' } })\n    move.mockResolvedValue({ data: { _id: 'moved-file' } })\n    resolveNameConflictsForCut.mockResolvedValue(mockFiles)\n    hasOneOfEntriesShared.mockReturnValue(false)\n\n    jest.clearAllMocks()\n  })\n\n  describe('Copy Operations', () => {\n    it('should copy files successfully', async () => {\n      const result = await handlePasteOperation(\n        mockClient,\n        mockFiles,\n        'copy',\n        null, // sourceDirectory\n        mockTargetFolder,\n        mockOptions\n      )\n\n      expect(copy).toHaveBeenCalledTimes(2)\n      expect(copy).toHaveBeenCalledWith(\n        mockClient,\n        mockFiles[0],\n        mockTargetFolder\n      )\n      expect(copy).toHaveBeenCalledWith(\n        mockClient,\n        mockFiles[1],\n        mockTargetFolder\n      )\n\n      expect(result).toEqual([\n        {\n          success: true,\n          file: { data: { _id: 'copied-file' } },\n          operation: 'copy'\n        },\n        {\n          success: true,\n          file: { data: { _id: 'copied-file' } },\n          operation: 'copy'\n        }\n      ])\n    })\n\n    it('should not resolve name conflicts for copy operations', async () => {\n      await handlePasteOperation(\n        mockClient,\n        mockFiles,\n        'copy',\n        null, // sourceDirectory\n        mockTargetFolder,\n        mockOptions\n      )\n\n      expect(resolveNameConflictsForCut).not.toHaveBeenCalled()\n    })\n  })\n\n  describe('Cut Operations', () => {\n    it('should move files successfully', async () => {\n      const result = await handlePasteOperation(\n        mockClient,\n        mockFiles,\n        'cut',\n        mockSourceDirectory,\n        mockTargetFolder,\n        mockOptions\n      )\n\n      expect(resolveNameConflictsForCut).toHaveBeenCalledWith(\n        mockClient,\n        mockFiles,\n        mockTargetFolder,\n        undefined\n      )\n\n      expect(move).toHaveBeenCalledTimes(2)\n      expect(move).toHaveBeenCalledWith(\n        mockClient,\n        mockFiles[0],\n        mockTargetFolder,\n        { force: false }\n      )\n      expect(move).toHaveBeenCalledWith(\n        mockClient,\n        mockFiles[1],\n        mockTargetFolder,\n        { force: false }\n      )\n\n      expect(result).toEqual([\n        {\n          success: true,\n          file: { data: { _id: 'moved-file' } },\n          operation: 'move'\n        },\n        {\n          success: true,\n          file: { data: { _id: 'moved-file' } },\n          operation: 'move'\n        }\n      ])\n    })\n\n    it('should use resolved names for cut operations', async () => {\n      const resolvedFiles = [\n        {\n          ...mockFiles[0],\n          needsRename: true,\n          uniqueName: 'test1 (1).txt',\n          attributes: { name: 'test1 (1).txt' },\n          _rev: 'rev1',\n          meta: { rev: 'rev1' }\n        },\n        {\n          ...mockFiles[1],\n          needsRename: false,\n          uniqueName: 'test2.txt',\n          attributes: { name: 'test2.txt' }\n        }\n      ]\n\n      resolveNameConflictsForCut.mockResolvedValue(resolvedFiles)\n\n      await handlePasteOperation(\n        mockClient,\n        mockFiles,\n        'cut',\n        mockSourceDirectory,\n        mockTargetFolder,\n        mockOptions\n      )\n\n      expect(move).toHaveBeenCalledTimes(2)\n      expect(move).toHaveBeenCalledWith(\n        mockClient,\n        resolvedFiles[0],\n        mockTargetFolder,\n        { force: false }\n      )\n      expect(move).toHaveBeenCalledWith(\n        mockClient,\n        resolvedFiles[1],\n        mockTargetFolder,\n        { force: false }\n      )\n    })\n  })\n\n  describe('Sharing Context', () => {\n    it('should handle shared files with sharing context', async () => {\n      hasOneOfEntriesShared.mockReturnValue(true)\n\n      const sharingContext = {\n        showMoveValidationModal: jest.fn(),\n        hideMoveValidationModal: jest.fn()\n      }\n\n      mockOptions.sharingContext = sharingContext\n\n      await handlePasteOperation(\n        mockClient,\n        mockFiles,\n        'cut',\n        mockSourceDirectory,\n        mockTargetFolder,\n        mockOptions\n      )\n\n      // Should still process files normally\n      expect(move).toHaveBeenCalledTimes(2)\n    })\n\n    it('should handle files without sharing context when shared', async () => {\n      hasOneOfEntriesShared.mockReturnValue(true)\n\n      // No sharing context provided\n      delete mockOptions.sharingContext\n\n      const result = await handlePasteOperation(\n        mockClient,\n        mockFiles,\n        'cut',\n        mockSourceDirectory,\n        mockTargetFolder,\n        mockOptions\n      )\n\n      // Should still process files\n      expect(result).toHaveLength(2)\n      expect(move).toHaveBeenCalledTimes(2)\n    })\n  })\n\n  describe('Nextcloud Integration', () => {\n    it('should handle Nextcloud files', async () => {\n      const nextcloudFiles = [\n        {\n          _id: 'nextcloud-file',\n          name: 'nextcloud.txt',\n          type: 'file',\n          attributes: { name: 'nextcloud.txt' },\n          cozyMetadata: { sourceAccount: 'nextcloud-account' }\n        }\n      ]\n\n      await handlePasteOperation(\n        mockClient,\n        nextcloudFiles,\n        'copy',\n        null, // sourceDirectory\n        mockTargetFolder,\n        mockOptions\n      )\n\n      expect(copy).toHaveBeenCalledWith(\n        mockClient,\n        nextcloudFiles[0],\n        mockTargetFolder\n      )\n    })\n  })\n\n  describe('Edge Cases', () => {\n    it('should handle empty files array', async () => {\n      const result = await handlePasteOperation(\n        mockClient,\n        [],\n        'copy',\n        null, // sourceDirectory\n        mockTargetFolder,\n        mockOptions\n      )\n\n      expect(result).toEqual([])\n      expect(copy).not.toHaveBeenCalled()\n      expect(move).not.toHaveBeenCalled()\n    })\n\n    it('should handle null files array', async () => {\n      await expect(\n        handlePasteOperation(\n          mockClient,\n          null,\n          'copy',\n          null, // sourceDirectory\n          mockTargetFolder,\n          mockOptions\n        )\n      ).rejects.toThrow('processedFiles is not iterable')\n\n      expect(copy).not.toHaveBeenCalled()\n    })\n\n    it('should handle invalid operation type', async () => {\n      const result = await handlePasteOperation(\n        mockClient,\n        mockFiles,\n        'invalid-operation',\n        null, // sourceDirectory\n        mockTargetFolder,\n        mockOptions\n      )\n\n      // Should default to no operation\n      expect(result).toEqual([])\n      expect(copy).not.toHaveBeenCalled()\n      expect(move).not.toHaveBeenCalled()\n    })\n\n    it('should handle missing target folder', async () => {\n      const result = await handlePasteOperation(\n        mockClient,\n        mockFiles,\n        'copy',\n        null, // sourceDirectory\n        null, // targetFolder\n        mockOptions\n      )\n\n      // Should return error results for each file\n      expect(result).toHaveLength(2)\n      expect(result[0].success).toBe(false)\n      expect(result[1].success).toBe(false)\n      expect(result[0].error).toBeInstanceOf(Error)\n      expect(result[1].error).toBeInstanceOf(Error)\n      expect(copy).toHaveBeenCalledTimes(2)\n    })\n\n    it('should handle missing options', async () => {\n      const result = await handlePasteOperation(\n        mockClient,\n        mockFiles,\n        'copy',\n        null, // sourceDirectory\n        mockTargetFolder\n      )\n\n      // Should still work without options\n      expect(result).toHaveLength(2)\n      expect(copy).toHaveBeenCalledTimes(2)\n    })\n  })\n\n  describe('Mixed File Types', () => {\n    it('should handle both files and folders', async () => {\n      const mixedFiles = [\n        {\n          _id: 'file1',\n          name: 'test.txt',\n          type: 'file',\n          attributes: { name: 'test.txt' }\n        },\n        {\n          _id: 'folder1',\n          name: 'Test Folder',\n          type: 'directory',\n          attributes: { name: 'Test Folder' }\n        }\n      ]\n\n      isFile.mockImplementation(item => item.type === 'file')\n\n      const result = await handlePasteOperation(\n        mockClient,\n        mixedFiles,\n        'copy',\n        null, // sourceDirectory\n        mockTargetFolder,\n        mockOptions\n      )\n\n      expect(copy).toHaveBeenCalledTimes(1)\n      expect(result).toHaveLength(1)\n      expect(result.every(r => r.success)).toBe(true)\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/paste/utils.js",
    "content": "import { Q } from 'cozy-client'\nimport { isFile } from 'cozy-client/dist/models/file'\n\n/**\n * Extracts the base name, extension, and existing suffix from a file/folder name.\n * Handles numbered suffixes in parentheses and file extensions.\n *\n * @example\n * parseName(\"file (2).txt\", true) => { base: \"file\", extension: \".txt\", suffix: 2 }\n * parseName(\"folder (3)\", false) => { base: \"folder\", extension: \"\", suffix: 3 }\n * parseName(\"document.pdf\", true) => { base: \"document\", extension: \".pdf\", suffix: null }\n *\n * @param {string} name - The file or folder name to parse\n * @param {boolean} isFileItem - Whether the item is a file (true) or folder (false)\n * @returns {Object} Object containing base name, extension, and suffix\n * @returns {string} returns.base - The base name without extension or suffix\n * @returns {string} returns.extension - The file extension (empty for folders)\n * @returns {number|null} returns.suffix - The numeric suffix if present, null otherwise\n */\nconst parseName = (name, isFileItem) => {\n  let base = name\n  let extension = ''\n  let suffix = null\n\n  if (isFileItem) {\n    const lastDotIndex = name.lastIndexOf('.')\n    if (lastDotIndex > 0) {\n      base = name.substring(0, lastDotIndex)\n      extension = name.substring(lastDotIndex)\n    }\n  }\n\n  const match = base.match(/^(.*)\\s\\((\\d+)\\)$/)\n  if (match) {\n    base = match[1]\n    suffix = parseInt(match[2], 10)\n  }\n\n  return { base, extension, suffix }\n}\n\n/**\n * Generates a unique name not present in the given set of existing names.\n * Appends numbered suffixes in parentheses until a unique name is found.\n *\n * @example\n * generateUniqueNameWithSuffix(\"file.txt\", new Set([\"file.txt\"]), true)\n * // Returns: \"file (1).txt\"\n *\n * generateUniqueNameWithSuffix(\"folder (2)\", new Set([\"folder (2)\", \"folder (3)\"]), false)\n * // Returns: \"folder (4)\"\n *\n * @param {string} originalName - The original name to make unique\n * @param {Set<string>} existingNames - Set of names that already exist\n * @param {boolean} isFileItem - Whether the item is a file (true) or folder (false)\n * @returns {string} A unique name not present in existingNames\n */\nexport const generateUniqueNameWithSuffix = (\n  originalName,\n  existingNames,\n  isFileItem\n) => {\n  if (!existingNames.has(originalName)) {\n    return originalName\n  }\n\n  const { base, extension, suffix } = parseName(originalName, isFileItem)\n\n  let counter = suffix ? suffix + 1 : 1\n  let newName\n\n  do {\n    newName = `${base} (${counter})${extension}`\n    counter++\n  } while (existingNames.has(newName))\n\n  return newName\n}\n\n/**\n * Gets all existing items in a target folder to check for conflicts.\n * Queries for all non-trashed files and folders in the specified directory.\n *\n * @param {CozyClient} client - The cozy client instance\n * @param {import('cozy-client/types/types').IOCozyFile} targetFolder - The target folder object\n * @param {string} targetFolder._id - The folder's unique identifier\n * @returns {Promise<Set<string>>} Set of existing item names in the folder\n * @throws {Error} When targetFolder is invalid or missing _id\n */\nexport const getExistingItems = async (\n  client,\n  targetFolder,\n  isPublic = false\n) => {\n  if (!targetFolder || !targetFolder._id) {\n    throw new Error('Invalid targetFolder: missing _id')\n  }\n\n  if (isPublic) {\n    const { included } = await client\n      .collection('io.cozy.files')\n      .statById(targetFolder._id)\n    return new Set(included?.map(item => item.name))\n  }\n\n  const query = Q('io.cozy.files')\n    .where({\n      dir_id: targetFolder._id,\n      trashed: false\n    })\n    .indexFields(['dir_id', 'trashed'])\n\n  const result = await client.query(query)\n  const items = result.data || []\n  return new Set(items.map(item => item.name))\n}\n\n/**\n * Resolves name conflicts by generating unique names for files/folders to be moved.\n *\n * @param {CozyClient} client - The cozy client instance\n * @param {Array<import('cozy-client/types/types').IOCozyFile>} files - Array of files/folders to be moved\n * @param {import('cozy-client/types/types').IOCozyFile} targetFolder - The target folder object\n * @param {string} targetFolder._id - The folder's unique identifier\n * @returns {Promise<Array<import('cozy-client/types/types').IOCozyFile & { needsRename?: boolean; uniqueName?: string }>>} Array of files with resolved names and conflict flags\n * @returns {boolean} returns[].needsRename - Whether the file needs to be renamed\n * @returns {string} returns[].uniqueName - The unique name for the file\n * @throws {Error} When files is not an array\n *\n * @example\n * const files = [{ name: \"document.pdf\", _id: \"123\" }]\n * const resolved = await resolveNameConflictsForCut(client, files, targetFolder)\n * // If \"document.pdf\" exists, returns:\n * // [{ name: \"document.pdf\", uniqueName: \"document (1).pdf\", needsRename: true, ... }]\n */\nexport const resolveNameConflictsForCut = async (\n  client,\n  files,\n  targetFolder,\n  isPublic = false\n) => {\n  if (!Array.isArray(files)) {\n    throw new Error('files must be an array')\n  }\n\n  const existingNames = await getExistingItems(client, targetFolder, isPublic)\n\n  const resolvedFiles = files.map(file => {\n    const isFileItem = isFile(file)\n    const originalName = file.name\n    const uniqueName = generateUniqueNameWithSuffix(\n      originalName,\n      existingNames,\n      isFileItem\n    )\n\n    // update the set so subsequent files don’t clash\n    existingNames.add(uniqueName)\n\n    return {\n      ...file,\n      needsRename: originalName !== uniqueName,\n      uniqueName,\n      attributes: {\n        ...file.attributes,\n        name: uniqueName\n      }\n    }\n  })\n\n  return resolvedFiles\n}\n"
  },
  {
    "path": "src/modules/paste/utils.spec.js",
    "content": "import {\n  generateUniqueNameWithSuffix,\n  resolveNameConflictsForCut\n} from './utils'\n\njest.mock('cozy-client/dist/models/file', () => ({\n  isFile: jest.fn()\n}))\n\nconst { isFile } = require('cozy-client/dist/models/file')\n\ndescribe('generateUniqueNameWithSuffix', () => {\n  beforeEach(() => {\n    jest.clearAllMocks()\n  })\n\n  describe('File naming', () => {\n    beforeEach(() => {\n      isFile.mockReturnValue(true)\n    })\n\n    it('should return original name if no conflict', () => {\n      const existingNames = new Set(['other.txt'])\n      const result = generateUniqueNameWithSuffix(\n        'test.txt',\n        existingNames,\n        true\n      )\n      expect(result).toBe('test.txt')\n    })\n\n    it('should add suffix for conflicting file names', () => {\n      const existingNames = new Set(['test.txt'])\n      const result = generateUniqueNameWithSuffix(\n        'test.txt',\n        existingNames,\n        true\n      )\n      expect(result).toBe('test (1).txt')\n    })\n\n    it('should increment suffix for multiple conflicts', () => {\n      const existingNames = new Set([\n        'test.txt',\n        'test (1).txt',\n        'test (2).txt'\n      ])\n      const result = generateUniqueNameWithSuffix(\n        'test.txt',\n        existingNames,\n        true\n      )\n      expect(result).toBe('test (3).txt')\n    })\n\n    it('should continue from existing suffix', () => {\n      const existingNames = new Set(['test (2).txt', 'test (3).txt'])\n      const result = generateUniqueNameWithSuffix(\n        'test (2).txt',\n        existingNames,\n        true\n      )\n      expect(result).toBe('test (4).txt')\n    })\n  })\n\n  describe('Folder naming', () => {\n    beforeEach(() => {\n      isFile.mockReturnValue(false)\n    })\n\n    it('should return original name if no conflict', () => {\n      const existingNames = new Set(['Other Folder'])\n      const result = generateUniqueNameWithSuffix(\n        'Test Folder',\n        existingNames,\n        false\n      )\n      expect(result).toBe('Test Folder')\n    })\n\n    it('should add suffix for conflicting folder names', () => {\n      const existingNames = new Set(['Test Folder'])\n      const result = generateUniqueNameWithSuffix(\n        'Test Folder',\n        existingNames,\n        false\n      )\n      expect(result).toBe('Test Folder (1)')\n    })\n\n    it('should increment suffix for multiple conflicts', () => {\n      const existingNames = new Set([\n        'Test Folder',\n        'Test Folder (1)',\n        'Test Folder (2)'\n      ])\n      const result = generateUniqueNameWithSuffix(\n        'Test Folder',\n        existingNames,\n        false\n      )\n      expect(result).toBe('Test Folder (3)')\n    })\n\n    it('should continue from existing suffix for folders', () => {\n      const existingNames = new Set(['Test Folder (5)', 'Test Folder (6)'])\n      const result = generateUniqueNameWithSuffix(\n        'Test Folder (5)',\n        existingNames,\n        false\n      )\n      expect(result).toBe('Test Folder (7)')\n    })\n  })\n})\n\ndescribe('resolveNameConflictsForCut', () => {\n  let mockClient\n\n  beforeEach(() => {\n    const mockStatById = jest.fn()\n    mockClient = {\n      query: jest.fn(),\n      collection: jest.fn(() => ({\n        statById: mockStatById\n      }))\n    }\n    // Store reference to statById mock for easy access in tests\n    mockClient.mockStatById = mockStatById\n\n    isFile.mockImplementation(file => file.type === 'file')\n    jest.clearAllMocks()\n  })\n\n  it('should resolve conflicts for files', async () => {\n    const files = [\n      {\n        _id: 'file1',\n        name: 'test.txt',\n        type: 'file',\n        attributes: { name: 'test.txt' }\n      },\n      {\n        _id: 'file2',\n        name: 'document.pdf',\n        type: 'file',\n        attributes: { name: 'document.pdf' }\n      }\n    ]\n\n    const existingItems = [{ name: 'test.txt' }, { name: 'other.txt' }]\n\n    mockClient.query.mockResolvedValue({ data: existingItems })\n\n    const targetFolder = { _id: 'target-folder' }\n    const result = await resolveNameConflictsForCut(\n      mockClient,\n      files,\n      targetFolder\n    )\n\n    expect(result).toHaveLength(2)\n\n    // First file should be renamed due to conflict\n    expect(result[0].needsRename).toBe(true)\n    expect(result[0].uniqueName).toBe('test (1).txt')\n    expect(result[0].attributes.name).toBe('test (1).txt')\n\n    // Second file should not be renamed (no conflict)\n    expect(result[1].needsRename).toBe(false)\n    expect(result[1].uniqueName).toBe('document.pdf')\n    expect(result[1].attributes.name).toBe('document.pdf')\n  })\n\n  it('should resolve conflicts for folders', async () => {\n    const folders = [\n      {\n        _id: 'folder1',\n        name: 'Documents',\n        type: 'directory',\n        attributes: { name: 'Documents' }\n      }\n    ]\n\n    const existingItems = [{ name: 'Documents' }, { name: 'Pictures' }]\n\n    mockClient.query.mockResolvedValue({ data: existingItems })\n\n    const targetFolder = { _id: 'target-folder' }\n    const result = await resolveNameConflictsForCut(\n      mockClient,\n      folders,\n      targetFolder\n    )\n\n    expect(result).toHaveLength(1)\n    expect(result[0].needsRename).toBe(true)\n    expect(result[0].uniqueName).toBe('Documents (1)')\n    expect(result[0].attributes.name).toBe('Documents (1)')\n  })\n\n  describe('Public mode (isPublic=true)', () => {\n    it('should resolve conflicts for files in public mode', async () => {\n      const files = [\n        {\n          _id: 'file1',\n          name: 'test.txt',\n          type: 'file',\n          attributes: { name: 'test.txt' }\n        },\n        {\n          _id: 'file2',\n          name: 'document.pdf',\n          type: 'file',\n          attributes: { name: 'document.pdf' }\n        }\n      ]\n\n      const existingItems = [{ name: 'test.txt' }, { name: 'other.txt' }]\n\n      mockClient.mockStatById.mockResolvedValue({\n        included: existingItems\n      })\n\n      const targetFolder = { _id: 'target-folder' }\n      const result = await resolveNameConflictsForCut(\n        mockClient,\n        files,\n        targetFolder,\n        true\n      )\n\n      expect(result).toHaveLength(2)\n\n      // First file should be renamed due to conflict\n      expect(result[0].needsRename).toBe(true)\n      expect(result[0].uniqueName).toBe('test (1).txt')\n      expect(result[0].attributes.name).toBe('test (1).txt')\n\n      // Second file should not be renamed (no conflict)\n      expect(result[1].needsRename).toBe(false)\n      expect(result[1].uniqueName).toBe('document.pdf')\n      expect(result[1].attributes.name).toBe('document.pdf')\n\n      // Should use collection.statById method for public mode\n      expect(mockClient.collection).toHaveBeenCalledWith('io.cozy.files')\n      expect(mockClient.mockStatById).toHaveBeenCalledWith('target-folder')\n      expect(mockClient.query).not.toHaveBeenCalled()\n    })\n\n    it('should resolve conflicts for folders in public mode', async () => {\n      const folders = [\n        {\n          _id: 'folder1',\n          name: 'Documents',\n          type: 'directory',\n          attributes: { name: 'Documents' }\n        }\n      ]\n\n      const existingItems = [{ name: 'Documents' }, { name: 'Pictures' }]\n\n      mockClient.mockStatById.mockResolvedValue({\n        included: existingItems\n      })\n\n      const targetFolder = { _id: 'target-folder' }\n      const result = await resolveNameConflictsForCut(\n        mockClient,\n        folders,\n        targetFolder,\n        true\n      )\n\n      expect(result).toHaveLength(1)\n      expect(result[0].needsRename).toBe(true)\n      expect(result[0].uniqueName).toBe('Documents (1)')\n      expect(result[0].attributes.name).toBe('Documents (1)')\n\n      // Should use collection.statById method for public mode\n      expect(mockClient.collection).toHaveBeenCalledWith('io.cozy.files')\n      expect(mockClient.mockStatById).toHaveBeenCalledWith('target-folder')\n      expect(mockClient.query).not.toHaveBeenCalled()\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/public/DownloadFilesButton.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { useClient } from 'cozy-client'\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport DownloadIcon from 'cozy-ui/transpiled/react/Icons/Download'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { downloadFiles } from '@/modules/actions/utils'\n\nexport const DownloadFilesButton = ({\n  files,\n  variant = 'secondary',\n  ...props\n}) => {\n  const { t } = useI18n()\n  const client = useClient()\n  const { showAlert } = useAlert()\n\n  const handleClick = () => {\n    downloadFiles(client, files, { showAlert, t })\n  }\n\n  return (\n    <Button\n      label={t('toolbar.menu_download')}\n      data-testid=\"fil-public-download\"\n      startIcon={<Icon icon={DownloadIcon} />}\n      onClick={handleClick}\n      variant={variant}\n      {...props}\n    />\n  )\n}\n\nDownloadFilesButton.propTypes = {\n  files: PropTypes.array.isRequired,\n  variant: PropTypes.string\n}\n"
  },
  {
    "path": "src/modules/public/LightFileViewer.jsx",
    "content": "import cx from 'classnames'\nimport PropTypes from 'prop-types'\nimport React, { useCallback } from 'react'\nimport { useLocation, useNavigate } from 'react-router-dom'\n\nimport { BarCenter } from 'cozy-bar'\nimport {\n  SharingBannerPlugin,\n  useSharingInfos,\n  OpenSharingLinkButton\n} from 'cozy-sharing'\nimport MidEllipsis from 'cozy-ui/transpiled/react/MidEllipsis'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport Viewer, {\n  FooterActionButtons,\n  ForwardOrDownloadButton\n} from 'cozy-viewer'\n\nimport styles from '@/modules/viewer/barviewer.styl'\n\nimport { FilesViewerLoading } from '@/components/FilesViewerLoading'\nimport PublicToolbar from '@/modules/public/PublicToolbar'\nimport {\n  isOfficeEnabled,\n  makeOnlyOfficeFileRoute\n} from '@/modules/views/OnlyOffice/helpers'\n\nconst LightFileViewer = ({ files, isPublic }) => {\n  const sharingInfos = useSharingInfos()\n  const { isDesktop, isMobile } = useBreakpoints()\n  const { pathname } = useLocation()\n  const navigate = useNavigate()\n  const { loading, isSharingShortcutCreated, addSharingLink } = sharingInfos\n\n  const onlyOfficeOpener = useCallback(\n    file => {\n      const route = makeOnlyOfficeFileRoute(file.id, {\n        fromPathname: pathname\n      })\n      navigate(route)\n    },\n    [navigate, pathname]\n  )\n\n  const isCozySharing = window.location.pathname === '/preview'\n  const isShareNotAdded = !loading && !isSharingShortcutCreated\n  const isSharingBannerPluginDisplayed = isShareNotAdded || !isCozySharing\n  const isAddToMyCozyDisplayed = isShareNotAdded && isCozySharing\n\n  if (loading) return <FilesViewerLoading />\n\n  return (\n    <div className={styles['viewer-wrapper-with-bar']}>\n      {isMobile && (\n        <BarCenter>\n          <Typography variant=\"h4\" noWrap className=\"u-ph-1 u-pt-half\">\n            <MidEllipsis text={files[0].name} />\n          </Typography>\n        </BarCenter>\n      )}\n      {isSharingBannerPluginDisplayed && <SharingBannerPlugin />}\n      {isMobile && (\n        <PublicToolbar\n          className={cx({ 'u-mt-1 u-mr-1': !isMobile })}\n          files={files}\n          sharingInfos={sharingInfos}\n        />\n      )}\n      <div className=\"u-pos-relative u-h-100\">\n        <Viewer\n          files={files}\n          isPublic={isPublic}\n          currentIndex={0}\n          disableModal\n          componentsProps={{\n            OnlyOfficeViewer: {\n              isEnabled: isOfficeEnabled(isDesktop),\n              opener: onlyOfficeOpener\n            },\n            toolbarProps: {\n              showToolbar: isDesktop,\n              showClose: false,\n              hideSummarizeBtn: true\n            }\n          }}\n        >\n          <FooterActionButtons>\n            {isAddToMyCozyDisplayed && (\n              <OpenSharingLinkButton\n                link={addSharingLink}\n                isSharingShortcutCreated={isSharingShortcutCreated}\n                isShortLabel\n                fullWidth\n                variant=\"secondary\"\n              />\n            )}\n            <ForwardOrDownloadButton\n              {...(isAddToMyCozyDisplayed ? { variant: 'buttonIcon' } : {})}\n            />\n          </FooterActionButtons>\n        </Viewer>\n      </div>\n    </div>\n  )\n}\n\nLightFileViewer.propTypes = {\n  files: PropTypes.array.isRequired,\n  isPublic: PropTypes.bool\n}\n\nexport default LightFileViewer\n"
  },
  {
    "path": "src/modules/public/LightFileViewer.spec.jsx",
    "content": "import { render } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport I18n from 'twake-i18n'\n\nimport LightFileViewer from './LightFileViewer'\nimport AppLike from 'test/components/AppLike'\n\njest.mock('cozy-keys-lib', () => ({\n  ...jest.requireActual('cozy-keys-lib'),\n  useVaultClient: jest.fn()\n}))\njest.mock('cozy-intent', () => ({\n  WebviewIntentProvider: ({ children }) => children,\n  useWebviewIntent: () => ({ call: () => {} })\n}))\njest.mock('cozy-ui/transpiled/react/providers/Breakpoints', () => ({\n  ...jest.requireActual('cozy-ui/transpiled/react/providers/Breakpoints'),\n  __esModule: true,\n  default: jest.fn()\n}))\n// used inside cozy-viewer\njest.mock('cozy-client/dist/models/permission', () => ({\n  ...jest.requireActual('cozy-client/dist/models/permission'),\n  isDocumentReadOnly: jest.fn().mockResolvedValue(false)\n}))\n\nconst client = new createMockClient({})\n\nconst setup = ({ isDesktop = false, isMobile = false } = {}) => {\n  useBreakpoints.mockReturnValue({ isDesktop, isMobile })\n  const root = render(\n    <AppLike client={client}>\n      <I18n lang=\"en\" dictRequire={() => ''}>\n        <LightFileViewer\n          files={[{ id: '01', type: 'file', name: 'fileName.txt' }]}\n        />\n      </I18n>\n    </AppLike>\n  )\n\n  return { root }\n}\n\ndescribe('LightFileViewer', () => {\n  describe('on Mobile and Tablet', () => {\n    it('should have the sharing banner and public toolbar but no viewer toolbar', () => {\n      jest.spyOn(console, 'error').mockImplementation() // TODO: to be removed with https://github.com/cozy/cozy-libs/pull/1457\n      jest.spyOn(console, 'warn').mockImplementation()\n\n      const { root } = setup({ isMobile: true })\n      const { queryByTestId, queryAllByRole } = root\n\n      expect(queryAllByRole('link')[0].getAttribute('href')).toBe(\n        'https://twake.app'\n      ) // This is the sharing banner\n      expect(queryByTestId('public-toolbar')).toBeTruthy()\n      expect(queryByTestId('viewer-toolbar')).toBeFalsy()\n    })\n  })\n\n  describe('on Desktop', () => {\n    it('should have the sharing banner and viewer toolbar but no public toolbar', () => {\n      const { root } = setup({ isDesktop: true })\n      const { queryByTestId, queryAllByRole } = root\n\n      expect(queryAllByRole('link')[0].getAttribute('href')).toBe(\n        'https://twake.app'\n      ) // This is the sharing banner\n      expect(queryByTestId('public-toolbar')).toBeFalsy()\n      expect(queryByTestId('viewer-toolbar')).toBeTruthy()\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/public/PublicLayout.jsx",
    "content": "import React from 'react'\nimport { Outlet } from 'react-router-dom'\n\nimport { BarComponent } from 'cozy-bar'\nimport FlagSwitcher from 'cozy-flags/dist/FlagSwitcher'\nimport Sprite from 'cozy-ui/transpiled/react/Icon/Sprite'\nimport { Layout } from 'cozy-ui/transpiled/react/Layout'\n\nimport Drive from '@/components/Icons/Drive'\nimport DriveText from '@/components/Icons/DriveText'\nimport { SelectionProvider } from '@/modules/selection/SelectionProvider'\nimport { NewItemHighlightProvider } from '@/modules/upload/NewItemHighlightProvider'\nimport UploadQueue from '@/modules/upload/UploadQueue'\n\nconst PublicLayout = () => {\n  return (\n    <Layout>\n      <BarComponent\n        replaceTitleOnMobile\n        isPublic\n        disableInternalStore\n        appIcon={Drive}\n        appTextIcon={DriveText}\n      />\n      <FlagSwitcher />\n      <UploadQueue />\n      <NewItemHighlightProvider>\n        <SelectionProvider>\n          <Outlet />\n        </SelectionProvider>\n      </NewItemHighlightProvider>\n      <Sprite />\n    </Layout>\n  )\n}\n\nexport default PublicLayout\n"
  },
  {
    "path": "src/modules/public/PublicProvider.tsx",
    "content": "import React, { createContext, useContext, ReactNode } from 'react'\n\ninterface PublicContextType {\n  isPublic: boolean\n}\n\nconst PublicContext = createContext<PublicContextType | undefined>({\n  isPublic: false\n})\n\ninterface PublicProviderProps {\n  children: ReactNode\n  isPublic?: boolean\n}\n\nconst PublicProvider: React.FC<PublicProviderProps> = ({\n  children,\n  isPublic = false\n}) => {\n  const value = {\n    isPublic\n  }\n\n  return (\n    <PublicContext.Provider value={value}>{children}</PublicContext.Provider>\n  )\n}\n\nconst usePublicContext = (): PublicContextType => {\n  const context = useContext(PublicContext)\n  if (context === undefined) {\n    throw new Error('usePublicContext must be used within a PublicProvider')\n  }\n  return context\n}\n\nexport { PublicProvider, usePublicContext }\n"
  },
  {
    "path": "src/modules/public/PublicToolbar.jsx",
    "content": "import cx from 'classnames'\nimport PropTypes from 'prop-types'\nimport React from 'react'\n\nimport PublicToolbarByLink from './PublicToolbarByLink'\nimport PublicToolbarCozyToCozy from './PublicToolbarCozyToCozy'\n\nconst PublicToolbar = ({\n  hasWriteAccess,\n  refreshFolderContent,\n  files,\n  sharingInfos,\n  className\n}) => {\n  const { loading, addSharingLink } = sharingInfos\n\n  if (loading) return null\n  return (\n    <div\n      className={cx('u-flex u-flex-justify-end', className)}\n      data-testid=\"public-toolbar\"\n    >\n      {!addSharingLink ? (\n        <PublicToolbarByLink\n          files={files}\n          hasWriteAccess={hasWriteAccess}\n          refreshFolderContent={refreshFolderContent}\n        />\n      ) : (\n        <PublicToolbarCozyToCozy files={files} sharingInfos={sharingInfos} />\n      )}\n    </div>\n  )\n}\n\nPublicToolbar.propTypes = {\n  files: PropTypes.array.isRequired,\n  // hasWriteAccess is only required if we're in a sharing by link\n  hasWriteAccess: PropTypes.bool,\n  // refreshFolderContent is not required if we're displaying only one file or in a cozy to cozy sharing\n  refreshFolderContent: PropTypes.func,\n  sharingInfos: PropTypes.object,\n  className: PropTypes.string\n}\n\nexport default PublicToolbar\n"
  },
  {
    "path": "src/modules/public/PublicToolbarByLink.jsx",
    "content": "import React from 'react'\n\nimport { useClient } from 'cozy-client'\nimport { useVaultClient } from 'cozy-keys-lib'\nimport { createCozySharingLink, useSharingInfos } from 'cozy-sharing'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { BarRightOnMobile } from '@/components/Bar'\nimport { useDisplayedFolder } from '@/hooks'\nimport { addItems, download, hr, select } from '@/modules/actions'\nimport AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'\nimport AddButton from '@/modules/drive/Toolbar/components/AddButton'\nimport ViewSwitcher from '@/modules/drive/Toolbar/components/ViewSwitcher'\nimport { DownloadFilesButton } from '@/modules/public/DownloadFilesButton'\nimport PublicToolbarMoreMenu from '@/modules/public/PublicToolbarMoreMenu'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport UploadButton from '@/modules/upload/UploadButton'\n\nconst PublicToolbarByLink = ({\n  files,\n  hasWriteAccess,\n  refreshFolderContent\n}) => {\n  const { isMobile } = useBreakpoints()\n  const { displayedFolder } = useDisplayedFolder()\n  const { showSelectionBar, isSelectionBarVisible } = useSelectionContext()\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n  const client = useClient()\n  const vaultClient = useVaultClient()\n  const { createCozyLink } = useSharingInfos()\n\n  const isMoreMenuDisplayed = files.length > 1\n\n  const actions = makeActions(\n    [\n      isMobile && download,\n      files.length > 1 && select,\n      addItems,\n      isMobile && (files.length > 1 || hasWriteAccess) && hr,\n      isMobile && createCozySharingLink\n    ],\n    {\n      t,\n      showAlert,\n      client,\n      vaultClient,\n      showSelectionBar,\n      createCozyLink,\n      hasWriteAccess\n    }\n  )\n\n  return (\n    <BarRightOnMobile>\n      <AddMenuProvider\n        canCreateFolder={hasWriteAccess}\n        canUpload={hasWriteAccess}\n        refreshFolderContent={refreshFolderContent}\n        isPublic={true}\n        displayedFolder={displayedFolder}\n        isSelectionBarVisible={isSelectionBarVisible}\n        componentsProps={{ AddMenu: { isUploadDisabled: true } }}\n      >\n        {!isMobile && (\n          <>\n            {hasWriteAccess && (\n              <>\n                <AddButton className=\"u-mr-half\" isPublic />\n                <UploadButton\n                  className=\"u-mr-half\"\n                  label={t('upload.label')}\n                  displayedFolder={displayedFolder}\n                  onUploaded={refreshFolderContent}\n                />\n              </>\n            )}\n            {files.length > 0 && <DownloadFilesButton files={files} />}\n            <ViewSwitcher className=\"u-ml-half\" />\n          </>\n        )}\n        {isMoreMenuDisplayed && (\n          <PublicToolbarMoreMenu\n            files={files}\n            hasWriteAccess={hasWriteAccess}\n            showSelectionBar={showSelectionBar}\n            actions={actions}\n          />\n        )}\n      </AddMenuProvider>\n    </BarRightOnMobile>\n  )\n}\n\nexport default PublicToolbarByLink\n"
  },
  {
    "path": "src/modules/public/PublicToolbarCozyToCozy.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { useClient } from 'cozy-client'\nimport { useVaultClient } from 'cozy-keys-lib'\nimport {\n  addToCozySharingLink,\n  syncToCozySharingLink,\n  OpenSharingLinkButton\n} from 'cozy-sharing'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { BarRightOnMobile } from '@/components/Bar'\nimport useCurrentFolderId from '@/hooks/useCurrentFolderId'\nimport { download, hr, select } from '@/modules/actions'\nimport ViewSwitcher from '@/modules/drive/Toolbar/components/ViewSwitcher'\nimport { DownloadFilesButton } from '@/modules/public/DownloadFilesButton'\nimport PublicToolbarMoreMenu from '@/modules/public/PublicToolbarMoreMenu'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\n\nconst PublicToolbarCozyToCozy = ({ sharingInfos, files }) => {\n  const {\n    loading,\n    addSharingLink,\n    syncSharingLink,\n    sharing,\n    isSharingShortcutCreated\n  } = sharingInfos\n  const { isMobile } = useBreakpoints()\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n  const client = useClient()\n  const { showSelectionBar } = useSelectionContext()\n  const vaultClient = useVaultClient()\n  const currentFolderId = useCurrentFolderId()\n  // Sharing can be a folder or a file\n  const itemId = currentFolderId ?? files[0]?._id\n\n  const isOnSharedFolder =\n    !loading && sharing?.rules?.some(rule => rule.values.includes(itemId))\n\n  const actions = makeActions(\n    [\n      isMobile && download,\n      files.length > 1 && select,\n      ((isMobile && files.length > 0) || files.length > 1) && hr,\n      isOnSharedFolder && addToCozySharingLink,\n      isOnSharedFolder && syncToCozySharingLink\n    ],\n    {\n      t,\n      showAlert,\n      client,\n      vaultClient,\n      showSelectionBar,\n      isSharingShortcutCreated,\n      addSharingLink,\n      syncSharingLink\n    }\n  )\n\n  return (\n    <BarRightOnMobile>\n      {!isMobile && (\n        <>\n          {files.length > 0 && <DownloadFilesButton files={files} />}\n          {!isSharingShortcutCreated && isOnSharedFolder && (\n            <OpenSharingLinkButton\n              className=\"u-ml-half\"\n              link={addSharingLink}\n              isSharingShortcutCreated={isSharingShortcutCreated}\n            />\n          )}\n          <ViewSwitcher className=\"u-ml-half\" />\n        </>\n      )}\n      <PublicToolbarMoreMenu\n        files={files}\n        showSelectionBar={showSelectionBar}\n        actions={actions}\n      />\n    </BarRightOnMobile>\n  )\n}\n\nPublicToolbarCozyToCozy.propTypes = {\n  files: PropTypes.array.isRequired,\n  sharingInfos: PropTypes.object.isRequired\n}\n\nexport default PublicToolbarCozyToCozy\n"
  },
  {
    "path": "src/modules/public/PublicToolbarMoreMenu.jsx",
    "content": "import cx from 'classnames'\nimport React, { useState, useCallback, useRef } from 'react'\n\nimport ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { MoreButton } from '@/components/Button'\n\nconst PublicToolbarMoreMenu = ({ files, actions }) => {\n  const moreButtonRef = useRef()\n  const { isMobile } = useBreakpoints()\n\n  const [menuIsVisible, setMenuVisible] = useState(false)\n\n  const openMenu = useCallback(() => setMenuVisible(true), [setMenuVisible])\n  const closeMenu = useCallback(() => setMenuVisible(false), [setMenuVisible])\n  const toggleMenu = useCallback(() => {\n    if (menuIsVisible) return closeMenu()\n    openMenu()\n  }, [closeMenu, openMenu, menuIsVisible])\n\n  if (actions.length === 0) return null\n\n  return (\n    <>\n      <div\n        ref={moreButtonRef}\n        data-testid=\"more-menu\"\n        className={cx({\n          'u-ml-half': !isMobile\n        })}\n      >\n        <MoreButton onClick={toggleMenu} />\n      </div>\n      {menuIsVisible && (\n        <ActionsMenu\n          open\n          onClose={closeMenu}\n          ref={moreButtonRef}\n          docs={files}\n          actions={actions}\n        />\n      )}\n    </>\n  )\n}\n\nexport default PublicToolbarMoreMenu\n"
  },
  {
    "path": "src/modules/public/helpers.js",
    "content": "export function isFilesIsFile(files) {\n  return files.length === 1 && files[0].type === 'file'\n}\n"
  },
  {
    "path": "src/modules/routeUtils.js",
    "content": "export const getFolderPath = folderId => {\n  return `/folder/${folderId}`\n}\n\nexport const getViewerPath = (folderId, fileId) => {\n  return `/folder/${folderId}/file/${fileId}`\n}\n\nexport const getSharedDrivePath = (driveId, folderId) => {\n  return `/shareddrive/${driveId}/${folderId}`\n}\n\nexport const getSharedDriveViewerPath = (driveId, folderId, fileId) => {\n  return `/shareddrive/${driveId}/${folderId}/file/${fileId}`\n}\n"
  },
  {
    "path": "src/modules/search/components/BarSearchAutosuggest.jsx",
    "content": "import cx from 'classnames'\nimport React, { useState } from 'react'\nimport Autosuggest from 'react-autosuggest'\n\nimport { models, useClient } from 'cozy-client'\nimport { isFlagshipApp } from 'cozy-device-helper'\nimport { useWebviewIntent } from 'cozy-intent'\nimport List from 'cozy-ui/transpiled/react/List'\n\nimport styles from '@/modules/search/components/styles.styl'\n\nimport { SHARED_DRIVES_DIR_ID } from '@/constants/config'\nimport BarSearchInputGroup from '@/modules/search/components/BarSearchInputGroup'\nimport SuggestionItem from '@/modules/search/components/SuggestionItem'\nimport SuggestionListSkeleton from '@/modules/search/components/SuggestionListSkeleton'\nimport useSearch from '@/modules/search/hooks/useSearch'\n\nconst BarSearchAutosuggest = ({ t }) => {\n  const webviewIntent = useWebviewIntent()\n  const client = useClient()\n\n  const [input, setInput] = useState('')\n  const [searchTerm, setSearchTerm] = useState('')\n  const { suggestions, hasSuggestions, isBusy, query, makeIndexes } =\n    useSearch(searchTerm)\n  const [focused, setFocused] = useState(false)\n\n  const theme = {\n    container: 'u-w-100',\n    suggestionsContainer:\n      styles['bar-search-autosuggest-suggestions-container'],\n    suggestionsContainerOpen:\n      styles['bar-search-autosuggest-suggestions-container--open'],\n    suggestionsList: styles['bar-search-autosuggest-suggestions-list']\n  }\n\n  const onSuggestionsFetchRequested = ({ value }) => {\n    setSearchTerm(value)\n  }\n  const onSuggestionsClearRequested = () => {\n    setSearchTerm('')\n  }\n\n  const cleanSearch = () => {\n    setInput('')\n    setSearchTerm('')\n  }\n\n  const onSuggestionSelected = async (event, { suggestion }) => {\n    // Open the shared drive in a new tab\n    if (suggestion.parentUrl?.includes(SHARED_DRIVES_DIR_ID)) {\n      window.open(`/#/external/${suggestion.id}`, '_blank')\n      return cleanSearch()\n    }\n\n    let url = `${window.location.origin}/#${suggestion.url}`\n    if (suggestion.openOn === 'notes') {\n      url = await models.note.fetchURL(client, {\n        id: suggestion.url.substr(3)\n      })\n    }\n\n    if (url) {\n      if (isFlagshipApp()) {\n        webviewIntent.call('openApp', url, { slug: suggestion.openOn })\n      } else {\n        window.location.assign(url)\n      }\n    } else {\n      // eslint-disable-next-line no-console\n      console.error(`openSuggestion (${suggestion.name}) could not be executed`)\n    }\n    cleanSearch()\n  }\n\n  // We want the user to find folders in which he can then navigate into, so we return the path here\n  const getSuggestionValue = suggestion => suggestion.subtitle\n\n  const renderSuggestion = suggestion => {\n    return (\n      <SuggestionItem\n        suggestion={suggestion}\n        query={query}\n        onParentOpened={cleanSearch}\n      />\n    )\n  }\n\n  const inputProps = {\n    placeholder: t('searchbar.placeholder'),\n    value: input,\n    onChange: (event, { newValue }) => {\n      setInput(newValue)\n    },\n    onFocus: () => {\n      makeIndexes()\n      setFocused(true)\n    },\n    onBlur: () => setFocused(false)\n  }\n\n  const renderInputComponent = inputProps => (\n    <BarSearchInputGroup isInputNotEmpty={input !== ''} onClean={cleanSearch}>\n      <input {...inputProps} />\n    </BarSearchInputGroup>\n  )\n\n  const renderSuggestionsContainer = ({ containerProps, children }) => {\n    return <List {...containerProps}>{children}</List>\n  }\n\n  const hasNoSearchResult = searchTerm !== '' && focused && !hasSuggestions\n\n  return (\n    <div className={styles['bar-search-container']} role=\"search\">\n      <Autosuggest\n        theme={theme}\n        suggestions={suggestions}\n        onSuggestionsFetchRequested={onSuggestionsFetchRequested}\n        onSuggestionsClearRequested={onSuggestionsClearRequested}\n        onSuggestionSelected={onSuggestionSelected}\n        getSuggestionValue={getSuggestionValue}\n        renderSuggestion={renderSuggestion}\n        renderInputComponent={renderInputComponent}\n        renderSuggestionsContainer={renderSuggestionsContainer}\n        inputProps={inputProps}\n        focusInputOnSuggestionClick={false}\n      />\n      {hasNoSearchResult && !isBusy && (\n        <div\n          className={cx(\n            styles['bar-search-autosuggest-status-container'],\n            styles['--empty']\n          )}\n        >\n          {t('searchbar.empty', { query })}\n        </div>\n      )}\n      {hasNoSearchResult && isBusy && (\n        <div className={styles['bar-search-autosuggest-status-container']}>\n          <SuggestionListSkeleton />\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport default BarSearchAutosuggest\n"
  },
  {
    "path": "src/modules/search/components/BarSearchInputGroup.jsx",
    "content": "import React from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport CrossCircleOutlineIcon from 'cozy-ui/transpiled/react/Icons/CrossCircleOutline'\nimport Magnifier from 'cozy-ui/transpiled/react/Icons/Magnifier'\nimport InputGroup from 'cozy-ui/transpiled/react/InputGroup'\n\nimport styles from '@/modules/search/components/styles.styl'\n\nconst BarSearchInputGroup = ({\n  children,\n  isMobile,\n  onClean,\n  isInputNotEmpty\n}) => {\n  return (\n    <InputGroup\n      fullwidth={true}\n      className={styles['bar-search-input-group']}\n      prepend={\n        !isMobile ? (\n          <Icon\n            icon={Magnifier}\n            className={styles['bar-search-input-group-append']}\n            aria-hidden=\"true\"\n          />\n        ) : null\n      }\n      append={\n        isInputNotEmpty ? (\n          <IconButton size=\"medium\" onClick={onClean}>\n            <Icon icon={CrossCircleOutlineIcon} />\n          </IconButton>\n        ) : null\n      }\n    >\n      {children}\n    </InputGroup>\n  )\n}\n\nexport default BarSearchInputGroup\n"
  },
  {
    "path": "src/modules/search/components/SearchEmpty.jsx",
    "content": "import React from 'react'\n\nimport Grid from 'cozy-ui/transpiled/react/Grid'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport { useI18n } from 'twake-i18n'\n\nimport searchEmptyIllustration from '@/assets/icons/icon-search-empty.svg'\n\nconst SearchEmpty = ({ query }) => {\n  const { t } = useI18n()\n\n  return (\n    <Grid\n      container\n      direction=\"column\"\n      justifyContent=\"center\"\n      alignItems=\"center\"\n      className=\"u-m-auto u-maw-5 u-ta-center\"\n      spacing={1}\n    >\n      <Grid item>\n        <Icon\n          width={96}\n          height={96}\n          icon={searchEmptyIllustration}\n          aria-hidden=\"true\"\n        />\n      </Grid>\n      <Grid item>\n        <Typography variant=\"h3\">\n          {t('search.empty.title', { query })}\n        </Typography>\n      </Grid>\n      <Grid item>\n        <Typography variant=\"body1\" color=\"textSecondary\">\n          {t('search.empty.subtitle', { query })}\n        </Typography>\n      </Grid>\n    </Grid>\n  )\n}\n\nexport default SearchEmpty\n"
  },
  {
    "path": "src/modules/search/components/SuggestionItem.jsx",
    "content": "import React, { useCallback } from 'react'\n\nimport ListItem from 'cozy-ui/transpiled/react/ListItem'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { SHARED_DRIVES_DIR_ID } from '@/constants/config'\nimport FileIconMime from '@/modules/filelist/icons/FileIconMime'\nimport FileIconShortcut from '@/modules/filelist/icons/FileIconShortcut'\nimport SuggestionItemTextHighlighted from '@/modules/search/components/SuggestionItemTextHighlighted'\nimport SuggestionItemTextSecondary from '@/modules/search/components/SuggestionItemTextSecondary'\n\nconst SuggestionItem = ({\n  suggestion,\n  query,\n  onClick,\n  onParentOpened,\n  isMobile = false\n}) => {\n  const openSuggestion = useCallback(() => {\n    if (typeof onClick == 'function') {\n      onClick(suggestion)\n    }\n  }, [onClick, suggestion])\n\n  const file = {\n    class: suggestion.class,\n    type: suggestion.type,\n    mime: suggestion.mime,\n    name: suggestion.title.replace(/\\.url$/, ''), // Not using `splitFileName()` because we don't have access to the full file here.\n    parentUrl: suggestion.parentUrl\n  }\n\n  return (\n    <ListItem button onClick={openSuggestion}>\n      <ListItemIcon>\n        {file.class === 'shortcut' ? (\n          <FileIconShortcut file={file} />\n        ) : (\n          <FileIconMime file={file} />\n        )}\n      </ListItemIcon>\n      <ListItemText\n        primary={\n          <SuggestionItemTextHighlighted text={file.name} query={query} />\n        }\n        secondary={\n          file.parentUrl?.includes(SHARED_DRIVES_DIR_ID) ? null : (\n            <SuggestionItemTextSecondary\n              text={suggestion.subtitle}\n              url={suggestion.parentUrl}\n              query={query}\n              onOpened={onParentOpened}\n              isMobile={isMobile}\n            />\n          )\n        }\n      />\n    </ListItem>\n  )\n}\n\nexport default SuggestionItem\n"
  },
  {
    "path": "src/modules/search/components/SuggestionItemSkeleton.jsx",
    "content": "import React from 'react'\n\nimport ListItem from 'cozy-ui/transpiled/react/ListItem'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport Skeleton from 'cozy-ui/transpiled/react/Skeleton'\n\nconst SuggestionItemSkeleton = () => {\n  return (\n    <ListItem>\n      <ListItemIcon>\n        <Skeleton\n          width={32}\n          height={32}\n          variant=\"rect\"\n          animation=\"wave\"\n          className=\"u-bdrs-4\"\n        />\n      </ListItemIcon>\n      <ListItemText\n        primary={\n          <Skeleton\n            width={196}\n            height={8}\n            variant=\"rect\"\n            animation=\"wave\"\n            className=\"u-bdrs-3 u-mb-half\"\n          />\n        }\n        secondary={\n          <Skeleton\n            width={96}\n            height={8}\n            variant=\"rect\"\n            animation=\"wave\"\n            className=\"u-bdrs-3\"\n          />\n        }\n      />\n    </ListItem>\n  )\n}\n\nexport default SuggestionItemSkeleton\n"
  },
  {
    "path": "src/modules/search/components/SuggestionItemTextHighlighted.jsx",
    "content": "import React from 'react'\n\nimport { normalizeString } from '@/modules/search/components/helpers'\n\n/**\n * Add <b> on part that equlas query into each result\n *\n * @param {Array} searchResult - list of results\n * @param {string} query - search input\n * @returns list of results with the query highlighted\n */\nconst highlightQueryTerms = (searchResult, query) => {\n  const normalizedQueryTerms = normalizeString(query)\n  const normalizedResultTerms = normalizeString(searchResult)\n\n  const matchedIntervals = []\n  const spacerLength = 1\n  let currentIndex = 0\n\n  normalizedResultTerms.forEach(resultTerm => {\n    normalizedQueryTerms.forEach(queryTerm => {\n      const index = resultTerm.indexOf(queryTerm)\n      if (index >= 0) {\n        matchedIntervals.push({\n          from: currentIndex + index,\n          to: currentIndex + index + queryTerm.length\n        })\n      }\n    })\n\n    currentIndex += resultTerm.length + spacerLength\n  })\n\n  // matchedIntervals can overlap, so we merge them.\n  // - sort the intervals by starting index\n  // - add the first interval to the stack\n  // - for every interval,\n  // - - add it to the stack if it doesn't overlap with the stack top\n  // - - or extend the stack top if the start overlaps and the new interval's top is bigger\n  const mergedIntervals = matchedIntervals\n    .sort((intervalA, intervalB) => intervalA.from > intervalB.from)\n    .reduce((computedIntervals, newInterval) => {\n      if (\n        computedIntervals.length === 0 ||\n        computedIntervals[computedIntervals.length - 1].to < newInterval.from\n      ) {\n        computedIntervals.push(newInterval)\n      } else if (\n        computedIntervals[computedIntervals.length - 1].to < newInterval.to\n      ) {\n        computedIntervals[computedIntervals.length - 1].to = newInterval.to\n      }\n\n      return computedIntervals\n    }, [])\n\n  // create an array containing the entire search result, with special characters, and the intervals surrounded y `<b>` tags\n  const slicedOriginalResult =\n    mergedIntervals.length > 0\n      ? [<span key=\"0\">{searchResult.slice(0, mergedIntervals[0].from)}</span>]\n      : searchResult\n\n  for (let i = 0, l = mergedIntervals.length; i < l; ++i) {\n    slicedOriginalResult.push(\n      <span className=\"u-primaryColor\">\n        {searchResult.slice(mergedIntervals[i].from, mergedIntervals[i].to)}\n      </span>\n    )\n    if (i + 1 < l)\n      slicedOriginalResult.push(\n        <span>\n          {searchResult.slice(\n            mergedIntervals[i].to,\n            mergedIntervals[i + 1].from\n          )}\n        </span>\n      )\n  }\n\n  if (mergedIntervals.length > 0)\n    slicedOriginalResult.push(\n      <span>\n        {searchResult.slice(\n          mergedIntervals[mergedIntervals.length - 1].to,\n          searchResult.length\n        )}\n      </span>\n    )\n\n  return slicedOriginalResult\n}\n\nconst SuggestionItemTextHighlighted = ({ text, query }) => {\n  const textHighlighted = highlightQueryTerms(text, query)\n  if (Array.isArray(textHighlighted)) {\n    return textHighlighted.map((item, idx) => ({\n      ...item,\n      key: idx\n    }))\n  }\n  return textHighlighted\n}\n\nexport default SuggestionItemTextHighlighted\n"
  },
  {
    "path": "src/modules/search/components/SuggestionItemTextSecondary.jsx",
    "content": "import React from 'react'\n\nimport { generateWebLink, useClient } from 'cozy-client'\nimport { isFlagshipApp } from 'cozy-device-helper'\nimport AppLinker, { generateUniversalLink } from 'cozy-ui-plus/dist/AppLinker'\n\nimport styles from '@/modules/search/components/styles.styl'\n\nimport SuggestionItemTextHighlighted from '@/modules/search/components/SuggestionItemTextHighlighted'\n\nconst SuggestionItemTextSecondary = ({\n  text,\n  query,\n  url,\n  onOpened,\n  isMobile\n}) => {\n  const client = useClient()\n\n  if (isMobile) {\n    return <SuggestionItemTextHighlighted text={text} query={query} />\n  }\n\n  const app = {\n    slug: 'drive'\n  }\n\n  const { subdomain: subDomainType } = client.getInstanceOptions()\n  const generateLink = isFlagshipApp() ? generateUniversalLink : generateWebLink\n\n  const appWebRef =\n    app &&\n    generateLink({\n      slug: 'drive',\n      cozyUrl: client.getStackClient().uri,\n      subDomainType,\n      nativePath: url,\n      pathname: '/',\n      hash: url\n    })\n  return (\n    <AppLinker app={app} href={appWebRef}>\n      {({ onClick, href }) => (\n        <a\n          className={styles['suggestion-item-parent-link']}\n          href={href}\n          onClick={e => {\n            e.stopPropagation()\n            if (typeof onOpened == 'function') {\n              onOpened(e)\n            }\n            if (typeof onClick == 'function') {\n              onClick(e)\n            }\n          }}\n        >\n          <SuggestionItemTextHighlighted text={text} query={query} />\n        </a>\n      )}\n    </AppLinker>\n  )\n}\n\nexport default SuggestionItemTextSecondary\n"
  },
  {
    "path": "src/modules/search/components/SuggestionListSkeleton.jsx",
    "content": "import React from 'react'\n\nimport List from 'cozy-ui/transpiled/react/List'\n\nimport SuggestionItemSkeleton from '@/modules/search/components/SuggestionItemSkeleton'\n\nconst SuggestionListSkeleton = ({ count }) => (\n  <List>\n    {Array(count || 4)\n      .fill(1)\n      .map((_, i) => (\n        <SuggestionItemSkeleton key={i} />\n      ))}\n  </List>\n)\n\nexport default SuggestionListSkeleton\n"
  },
  {
    "path": "src/modules/search/components/helpers.js",
    "content": "import { models } from 'cozy-client'\n\nimport { ROOT_DIR_ID, SHARED_DRIVES_DIR_ID } from '@/constants/config'\nimport FuzzyPathSearch from '@/lib/FuzzyPathSearch.js'\nimport { makeOnlyOfficeFileRoute } from '@/modules/views/OnlyOffice/helpers'\n\nexport const TYPE_DIRECTORY = 'directory'\n\nexport const normalizeString = str =>\n  str\n    .toString()\n    .toLowerCase()\n    .replace(/\\//g, ' ')\n    .normalize('NFD')\n    .replace(/[\\u0300-\\u036f]/g, '')\n    .split(' ')\n\n/**\n * Normalize file for Front usage in <AutoSuggestion> component inside <BarSearchAutosuggest>\n *\n * To reduce API call, the fetching of Note URL has been delayed\n * inside an onSelect function called only if provided to <BarSearchAutosuggest>\n * see https://github.com/cozy/cozy-drive/pull/2663#discussion_r938671963\n *\n * @param {CozyClient} client - cozy client instance\n * @param {[IOCozyFile]} folders - all the folders returned by API\n * @param {IOCozyFile} file - file to normalize\n * @returns file with normalized field to be used in AutoSuggestion\n */\nexport const makeNormalizedFile = (client, folders, file) => {\n  const isDir = file.type === TYPE_DIRECTORY\n  const dirId = isDir ? file._id : file.dir_id\n  const urlToFolder = `/folder/${dirId}`\n\n  let path, url, parentUrl\n  let openOn = 'drive'\n  if (isDir) {\n    path = file.path\n    url = urlToFolder\n    parentUrl = urlToFolder\n  } else {\n    const parentDir = folders.find(folder => folder._id === file.dir_id)\n    path = parentDir && parentDir.path ? parentDir.path : ''\n    parentUrl = parentDir && parentDir._id ? `/folder/${parentDir._id}` : ''\n    if (models.file.isNote(file)) {\n      url = `/n/${file.id}`\n      openOn = 'notes'\n    } else if (models.file.shouldBeOpenedByOnlyOffice(file)) {\n      url = makeOnlyOfficeFileRoute(file.id, { fromPathname: urlToFolder })\n    } else {\n      url = `${urlToFolder}/file/${file._id}`\n    }\n  }\n\n  return {\n    id: file._id,\n    type: file.type,\n    name: file.name,\n    mime: file.mime,\n    class: file.class,\n    path,\n    url,\n    parentUrl,\n    openOn\n  }\n}\n\n/**\n * Fetches all files without trashed and preloads FuzzyPathSearch\n *\n * Using _all_docs route\n *\n * Also, this method:\n * - removing trashed data directly\n * - removes orphan file\n * - normalize file to match <SearchBar> expectation\n * - preloads FuzzyPathSearch\n *\n * @returns {Promise<void>} nothing\n */\nexport const indexFiles = async client => {\n  const resp = await client\n    .getStackClient()\n    .fetchJSON(\n      'GET',\n      '/data/io.cozy.files/_all_docs?Fields=_id,trashed,dir_id,name,path,type,mime,class,metadata.title,metadata.version&DesignDocs=false&include_docs=true'\n    )\n  const files = resp.rows.map(row => ({ id: row.id, ...row.doc }))\n  const folders = files.filter(file => file.type === TYPE_DIRECTORY)\n\n  const notInTrash = file => !file.trashed && !/^\\/\\.cozy_trash/.test(file.path)\n  const notOrphans = file =>\n    folders.find(folder => folder._id === file.dir_id) !== undefined\n  const notRoot = file => file._id !== ROOT_DIR_ID\n  // Shared drives folder to be hidden in search.\n  // The files inside it though must appear. Thus only the file with the folder ID is filtered out.\n  const notSharedDrivesDir = file => file._id !== SHARED_DRIVES_DIR_ID\n\n  const normalizedFilesPrevious = files.filter(\n    file =>\n      notInTrash(file) &&\n      notOrphans(file) &&\n      notRoot(file) &&\n      notSharedDrivesDir(file)\n  )\n\n  const normalizedFiles = normalizedFilesPrevious.map(file =>\n    makeNormalizedFile(client, folders, file)\n  )\n\n  return new FuzzyPathSearch(normalizedFiles)\n}\n"
  },
  {
    "path": "src/modules/search/components/helpers.spec.jsx",
    "content": "import { createMockClient, models } from 'cozy-client'\n\nimport { makeNormalizedFile, TYPE_DIRECTORY } from './helpers'\n\nmodels.note.fetchURL = jest.fn(() => 'noteUrl')\n\nconst client = createMockClient({})\n\nconst noteFileProps = {\n  name: 'note.cozy-note',\n  metadata: {\n    content: '',\n    schema: '',\n    title: '',\n    version: ''\n  }\n}\n\ndescribe('makeNormalizedFile', () => {\n  it('should return correct values for a directory', () => {\n    const folders = []\n    const file = {\n      _id: 'fileId',\n      type: TYPE_DIRECTORY,\n      path: 'filePath',\n      name: 'fileName'\n    }\n\n    const normalizedFile = makeNormalizedFile(client, folders, file)\n\n    expect(normalizedFile).toEqual({\n      id: 'fileId',\n      name: 'fileName',\n      path: 'filePath',\n      url: '/folder/fileId',\n      parentUrl: '/folder/fileId',\n      openOn: 'drive',\n      mime: undefined,\n      type: 'directory'\n    })\n  })\n\n  it('should return correct values for a file', () => {\n    const folders = [{ _id: 'folderId', path: 'folderPath' }]\n    const file = {\n      _id: 'fileId',\n      dir_id: 'folderId',\n      type: 'file',\n      name: 'fileName'\n    }\n\n    const normalizedFile = makeNormalizedFile(client, folders, file)\n\n    expect(normalizedFile).toEqual({\n      id: 'fileId',\n      name: 'fileName',\n      path: 'folderPath',\n      url: '/folder/folderId/file/fileId',\n      parentUrl: '/folder/folderId',\n      openOn: 'drive',\n      mime: undefined,\n      type: 'file'\n    })\n  })\n\n  it('should return correct values for a note with on Select function - better for performance', () => {\n    const folders = [{ _id: 'folderId', path: 'folderPath' }]\n    const file = {\n      _id: 'fileId',\n      id: 'noteId',\n      dir_id: 'folderId',\n      type: 'file',\n      name: 'fileName',\n      ...noteFileProps\n    }\n\n    const normalizedFile = makeNormalizedFile(client, folders, file)\n\n    expect(normalizedFile).toEqual({\n      id: 'fileId',\n      name: 'note.cozy-note',\n      path: 'folderPath',\n      url: '/n/noteId',\n      parentUrl: '/folder/folderId',\n      openOn: 'notes',\n      mime: undefined,\n      type: 'file'\n    })\n  })\n\n  it('should not return filled onSelect for a note without metadata', () => {\n    const folders = [{ _id: 'folderId', path: 'folderPath' }]\n    const file = {\n      _id: 'fileId',\n      id: 'noteId',\n      dir_id: 'folderId',\n      type: 'file',\n      name: 'note.cozy-note'\n    }\n\n    const normalizedFile = makeNormalizedFile(client, folders, file)\n\n    expect(normalizedFile).toEqual({\n      id: 'fileId',\n      name: 'note.cozy-note',\n      path: 'folderPath',\n      url: '/folder/folderId/file/fileId',\n      parentUrl: '/folder/folderId',\n      openOn: 'drive',\n      mime: undefined,\n      type: 'file'\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/search/components/styles.styl",
    "content": "[role=banner]\n    .bar-search-autosuggest-suggestions-container\n        position absolute\n        top 100%\n        width 100%\n        max-height em(170px)\n        overflow auto\n        border-radius .5em\n        color var(--primaryTextColor)\n        background var(--paperBackgroundColor)\n        box-shadow var(--shadow7)\n        display none\n        box-sizing border-box\n\n    .bar-search-autosuggest-suggestions-container--open\n        display block\n\n    .bar-search-autosuggest-status-container\n        position absolute\n        display flex\n        align-items center\n        top 100%\n        left 0\n        right 0\n        min-height 48px\n        max-height em(170px)\n        overflow auto\n        border-radius .5em\n        background var(--paperBackgroundColor)\n        box-shadow var(--shadow7)\n        box-sizing border-box\n\n        &.--empty\n            padding .75em 1em\n\n    .bar-search-autosuggest-suggestions-list\n        margin 0\n        padding 0\n        list-style none\n\n    .bar-search-container\n        position relative\n        display flex\n        align-items center\n        flex-grow 1\n        margin-left 2em\n        margin-right 2em\n        padding-top .25em\n        padding-bottom .25em\n\n        &.mobile\n            margin-left 0\n            margin-right -.5em\n\n        .bar-search-input-group\n            border 0\n            max-height 40px\n            padding-left .5em\n            border-radius 1.25em\n            background-color var(--defaultBackgroundColor)\n            transition all .2s ease-out\n            overflow hidden\n\n            &:hover\n                background linear-gradient(0deg, var(--actionColorHover), var(--actionColorHover)), var(--defaultBackgroundColor)\n\n            .bar-search-input-group-append\n                padding-left .5em\n                color var(--secondaryTextColor)\n\n            input\n                padding-left .5em\n                background-color transparent\n                max-width 100%\n                height 100%\n\n.suggestion-item-parent-link\n    color var(--secondaryTextColor)\n    text-decoration none\n\n    &:hover\n        text-decoration underline\n"
  },
  {
    "path": "src/modules/search/hooks/useSearch.jsx",
    "content": "import { useState, useEffect, useMemo } from 'react'\n\nimport { useClient } from 'cozy-client'\n\nimport useDebounce from '@/hooks/useDebounce'\nimport { indexFiles } from '@/modules/search/components/helpers'\n\nconst useSearch = (searchTerm, { limit = 10 } = {}) => {\n  const client = useClient()\n  const [allSuggestions, setAllSuggestions] = useState([])\n  const [suggestions, setSuggestions] = useState([])\n  const [fuzzy, setFuzzy] = useState(null)\n  const [isBusy, setBusy] = useState(true)\n  const [query, setQuery] = useState('')\n\n  const debouncedSearchTerm = useDebounce(searchTerm, {\n    delay: 500,\n    ignore: searchTerm === ''\n  })\n\n  const makeIndexes = async () => {\n    if (fuzzy == null) {\n      setFuzzy(await indexFiles(client))\n    }\n  }\n\n  useEffect(() => {\n    const fetchSuggestions = async value => {\n      setBusy(true)\n      let currentFuzzy = fuzzy\n      if (currentFuzzy == null) {\n        currentFuzzy = await indexFiles(client)\n        setFuzzy(currentFuzzy)\n      }\n      const suggestions = currentFuzzy.search(value).map(result => ({\n        id: result.id,\n        title: result.name,\n        subtitle: result.path,\n        url: result.url,\n        parentUrl: result.parentUrl,\n        openOn: result.openOn,\n        type: result.type,\n        mime: result.mime,\n        class: result.class\n      }))\n\n      setBusy(value === '') // To prevent empty state to appear at the first search\n      setQuery(value)\n      setAllSuggestions(suggestions)\n      setSuggestions(suggestions.slice(0, limit))\n    }\n\n    if (debouncedSearchTerm !== '') {\n      fetchSuggestions(debouncedSearchTerm)\n    } else {\n      // eslint-disable-next-line react-hooks/immutability\n      clearSuggestions()\n    }\n  }, [client, debouncedSearchTerm, fuzzy, limit])\n\n  const hasSuggestions = useMemo(() => suggestions.length > 0, [suggestions])\n\n  const hasMore = useMemo(\n    () => suggestions.length < allSuggestions.length,\n    [suggestions, allSuggestions]\n  )\n\n  const fetchMore = async () => {\n    setSuggestions(allSuggestions.slice(0, suggestions.length + limit))\n  }\n\n  const clearSuggestions = () => {\n    setBusy(true)\n    setQuery('')\n    setAllSuggestions([])\n    setSuggestions([])\n  }\n\n  return {\n    suggestions,\n    hasSuggestions,\n    hasMore,\n    isBusy,\n    query,\n    makeIndexes,\n    fetchMore\n  }\n}\n\nexport default useSearch\n"
  },
  {
    "path": "src/modules/selection/RectangularSelection.jsx",
    "content": "import React, { useRef, useCallback, useMemo, useState, useEffect } from 'react'\nimport Selecto from 'react-selecto'\n\nimport styles from './RectangularSelection.styl'\nimport { useSelectionContext } from './SelectionProvider'\n\nconst INTERACTIVE_ELEMENTS_SELECTOR =\n  'button,a,input,select,textarea,label,[role=\"button\"],[role=\"menuitem\"],[role=\"option\"]'\nconst SCROLL_STEP_IN_PIXELS = 10\n/**\n * Hit rate for the Selecto library.\n * Controls how frequently the selection rectangle checks for elements to select.\n * A value of 1 means it checks every pixel, ensuring precise selection.\n */\nconst HIT_RATE = 1\n\nconst buildSelectionFromItems = (fileIds, itemsMap) => {\n  const newSelection = {}\n  let lastSelectedId = null\n  for (const fileId of fileIds) {\n    const file = itemsMap.get(fileId)\n    if (file) {\n      newSelection[fileId] = file\n      lastSelectedId = fileId\n    }\n  }\n  return { newSelection, lastSelectedId }\n}\n\nconst getVisibleFileIdsFromSelecto = selectoRef => {\n  const selectableElements = selectoRef.current?.getSelectableElements() || []\n  const visibleFileIds = new Set()\n  for (const el of selectableElements) {\n    const fileId = el.getAttribute('data-file-id')\n    if (fileId) {\n      visibleFileIds.add(fileId)\n    }\n  }\n  return visibleFileIds\n}\n\nconst getSelectedFileIdsFromSelectoEvent = (e, getFileFromElement) => {\n  const selectedFileIds = new Set()\n  for (const el of e.selected) {\n    const file = getFileFromElement(el)\n    if (file) {\n      selectedFileIds.add(file._id)\n    }\n  }\n  return selectedFileIds\n}\n\nconst accumulateSelectedItemsDuringDrag = (\n  selectedDuringDragRef,\n  selectedFileIds,\n  visibleFileIds,\n  preserveAll\n) => {\n  const newAccumulated = new Set()\n  for (const fileId of selectedDuringDragRef.current) {\n    if (\n      preserveAll ||\n      !visibleFileIds.has(fileId) ||\n      selectedFileIds.has(fileId)\n    ) {\n      newAccumulated.add(fileId)\n    }\n  }\n  for (const fileId of selectedFileIds) {\n    newAccumulated.add(fileId)\n  }\n  return newAccumulated\n}\n\n/**\n * Component that enables rectangular selection of files in a grid view.\n * Wraps children with a selection area that allows users to drag-select\n * multiple files by drawing a selection rectangle.\n *\n * @param {Object} props - Component props\n * @param {React.ReactNode} props.children - Child elements to render inside the selection container\n * @param {Array<Object>} props.items - List of file items available for selection\n * @param {React.RefObject} props.scrollContainerRef - Ref to the scrollable container for auto-scroll during selection (fallback)\n * @param {HTMLElement|null} props.scrollElement - Direct HTMLElement for the scroll container (preferred over scrollContainerRef)\n * @returns {React.ReactElement} The rectangular selection wrapper component\n */\nconst RectangularSelection = ({\n  children,\n  items,\n  scrollContainerRef,\n  scrollElement,\n  onSelectEnd\n}) => {\n  const containerRef = useRef(null)\n  const selectoRef = useRef(null)\n  const [isContainerReady, setIsContainerReady] = useState(false)\n  const { setSelectedItems, selectedItems, setIsSelectAll } =\n    useSelectionContext()\n  const [resolvedScrollContainer, setResolvedScrollContainer] = useState(null)\n  const isDraggingRef = useRef(false)\n  const dragStartPosRef = useRef(null)\n  const wheelScrolledDuringDragRef = useRef(false)\n  const mutationObserverRef = useRef(null)\n  const selectedDuringDragRef = useRef(new Set())\n\n  useEffect(() => {\n    if (containerRef.current) {\n      setIsContainerReady(true)\n    }\n\n    return () => {\n      if (mutationObserverRef.current) {\n        mutationObserverRef.current.disconnect()\n      }\n    }\n  }, [])\n\n  useEffect(() => {\n    setResolvedScrollContainer(\n      scrollElement || scrollContainerRef?.current || null\n    )\n  }, [scrollElement, scrollContainerRef, isContainerReady])\n\n  /**\n   * Extracts file data from a DOM element using the data-file-id attribute.\n   * Uses a Map for O(1) lookups instead of O(n) array.find().\n   *\n   * @param {Element} el - DOM element with data-file-id attribute\n   * @returns {Object|undefined} The file object matching the element's ID, or undefined if not found\n   */\n  const itemsMap = useMemo(() => {\n    const map = new Map()\n    for (const item of items) {\n      map.set(item._id, item)\n    }\n    return map\n  }, [items])\n\n  const getFileFromElement = useCallback(\n    el => {\n      const fileId = el.getAttribute('data-file-id')\n      if (!fileId) return undefined\n      return itemsMap.get(fileId)\n    },\n    [itemsMap]\n  )\n\n  /**\n   * Handles the selection event from react-selecto.\n   * Updates the selected items state based on elements inside the selection rectangle.\n   * Supports additive selection when Ctrl/Cmd key is held.\n   * Optimized: tracks count directly instead of Object.keys().length\n   *\n   * @param {Object} e - Selecto event object\n   * @param {Array<Element>} e.selected - Array of DOM elements inside the selection rectangle\n   * @param {Object} e.inputEvent - The original input event with modifier key state\n   */\n  const handleSelect = useCallback(\n    e => {\n      const visibleFileIds = getVisibleFileIdsFromSelecto(selectoRef)\n      const selectedFileIds = getSelectedFileIdsFromSelectoEvent(\n        e,\n        getFileFromElement\n      )\n      // After a wheel scroll, items may still be in the DOM but outside\n      // the selection rectangle (content shifted, not rectangle shrunk).\n      // In that case, preserve all accumulated items to avoid losing them.\n      const newAccumulated = accumulateSelectedItemsDuringDrag(\n        selectedDuringDragRef,\n        selectedFileIds,\n        visibleFileIds,\n        wheelScrolledDuringDragRef.current\n      )\n      selectedDuringDragRef.current = newAccumulated\n\n      const { newSelection, lastSelectedId } = buildSelectionFromItems(\n        newAccumulated,\n        itemsMap\n      )\n\n      setSelectedItems(newSelection)\n      setIsSelectAll(Object.keys(newSelection).length === items.length)\n\n      if (lastSelectedId) {\n        onSelectEnd?.(lastSelectedId)\n      }\n    },\n    [\n      items.length,\n      itemsMap,\n      getFileFromElement,\n      setSelectedItems,\n      setIsSelectAll,\n      onSelectEnd\n    ]\n  )\n\n  /**\n   * Determines whether a drag operation should initiate rectangular selection.\n   * Prevents selection when clicking on interactive elements or directly on files.\n   *\n   * @param {Object} e - Selecto drag condition event\n   * @param {Object} e.inputEvent - The original input event\n   * @param {Element} e.inputEvent.target - The target element being clicked\n   * @returns {boolean} True if drag selection should proceed, false otherwise\n   */\n  const dragCondition = useCallback(e => {\n    const target = e.inputEvent?.target\n    if (!target) return true\n\n    const isInteractive = target.closest(INTERACTIVE_ELEMENTS_SELECTOR)\n    if (isInteractive) return false\n\n    const fileElement = target.closest('[data-file-id]')\n    return !fileElement\n  }, [])\n\n  /**\n   * Records the starting position of a drag operation.\n   * Used to distinguish between clicks and actual drag selections.\n   *\n   * @param {Object} e - Drag start event\n   * @param {number} e.clientX - X coordinate of the drag start\n   * @param {number} e.clientY - Y coordinate of the drag start\n   */\n  const handleDragStart = useCallback(\n    e => {\n      dragStartPosRef.current = { x: e.clientX, y: e.clientY }\n      isDraggingRef.current = false\n      selectedDuringDragRef.current.clear()\n\n      // If Ctrl/Cmd is pressed, start with current selection\n      if (e.inputEvent?.ctrlKey || e.inputEvent?.metaKey) {\n        for (const item of Object.values(selectedItems)) {\n          selectedDuringDragRef.current.add(item._id)\n        }\n      }\n    },\n    [selectedItems]\n  )\n\n  /**\n   * Handles drag movement during the selection.\n   * Calculates distance from drag start and marks it as a real drag if moved more than 5 pixels.\n   *\n   * @param {Object} e - Drag event\n   * @param {number} e.clientX - X coordinate of the drag\n   * @param {number} e.clientY - Y coordinate of the drag\n   */\n  const handleDrag = useCallback(e => {\n    const start = dragStartPosRef.current\n    if (!start) return\n\n    const dx = e.clientX - start.x\n    const dy = e.clientY - start.y\n\n    if (Math.hypot(dx, dy) > 5) {\n      isDraggingRef.current = true\n    }\n  }, [])\n\n  /**\n   * Handles the end of a drag operation.\n   * Cleans up the drag start position reference.\n   */\n  const handleDragEnd = useCallback(() => {\n    dragStartPosRef.current = null\n    wheelScrolledDuringDragRef.current = false\n    selectedDuringDragRef.current.clear()\n  }, [])\n\n  /**\n   * Sets up a MutationObserver on the scroll container to detect when\n   * virtuoso adds or removes DOM elements (e.g. after scrolling).\n   * When mutations are detected during a drag, we force selecto to\n   * re-discover selectable targets so newly rendered elements can be selected.\n   */\n  useEffect(() => {\n    if (!resolvedScrollContainer) return\n\n    if (mutationObserverRef.current) {\n      mutationObserverRef.current.disconnect()\n    }\n\n    const observer = new MutationObserver(() => {\n      if (isDraggingRef.current && selectoRef.current) {\n        selectoRef.current.findSelectableTargets()\n      }\n    })\n\n    observer.observe(resolvedScrollContainer, {\n      childList: true,\n      subtree: true\n    })\n    mutationObserverRef.current = observer\n\n    return () => observer.disconnect()\n  }, [resolvedScrollContainer])\n\n  /**\n   * Listens for mouse wheel scroll during a drag selection.\n   * Marks that a wheel scroll occurred so the accumulator preserves\n   * all previously selected items instead of dropping those that\n   * are still in the DOM but scrolled out of the selection rectangle.\n   */\n  useEffect(() => {\n    if (!resolvedScrollContainer) return\n\n    const handleWheel = () => {\n      if (!isDraggingRef.current) return\n      wheelScrolledDuringDragRef.current = true\n    }\n\n    resolvedScrollContainer.addEventListener('wheel', handleWheel, {\n      passive: true\n    })\n\n    return () =>\n      resolvedScrollContainer.removeEventListener('wheel', handleWheel)\n  }, [resolvedScrollContainer])\n\n  /**\n   * Handles scroll events from react-selecto during drag selection.\n   * When the selection rectangle reaches the edge of the scrollable container,\n   * Selecto fires this event and we must manually scroll the container.\n   *\n   * New elements rendered by virtuoso after scrolling are detected by the\n   * MutationObserver which triggers selecto to re-check selectable targets.\n   *\n   * @param {Object} e - Selecto scroll event\n   * @param {number[]} e.direction - Scroll direction [x, y], each -1, 0, or 1\n   */\n  const handleScroll = useCallback(\n    e => {\n      if (!resolvedScrollContainer) return\n\n      resolvedScrollContainer.scrollBy(\n        e.direction[0] * SCROLL_STEP_IN_PIXELS,\n        e.direction[1] * SCROLL_STEP_IN_PIXELS\n      )\n    },\n    [resolvedScrollContainer]\n  )\n\n  /**\n   * Handles clicks on the container to clear selection when clicking empty space.\n   * Skips if the click was part of a drag operation or if Ctrl/Cmd is pressed.\n   * Prevents clearing when clicking on files or interactive elements.\n   *\n   * @param {React.MouseEvent} e - Click event\n   */\n  const handleContainerClick = useCallback(\n    e => {\n      // Early return if this click was part of a drag operation (rectangular selection)\n      if (isDraggingRef.current) {\n        e.stopPropagation()\n        e.preventDefault()\n        return\n      }\n\n      // Early return if Ctrl/Cmd is pressed (user wants to add to selection)\n      if (e.ctrlKey || e.metaKey) return\n\n      const target = e.target\n\n      // Early return if clicked on a file\n      if (target.closest('[data-file-id]')) return\n\n      // Early return if clicked on interactive element\n      if (target.closest(INTERACTIVE_ELEMENTS_SELECTOR)) return\n\n      // If clicked in empty space, clear selection\n      setSelectedItems({})\n      setIsSelectAll(false)\n    },\n    [setSelectedItems, setIsSelectAll]\n  )\n\n  return (\n    <div\n      ref={containerRef}\n      className=\"u-h-100 rectangular-selection-container\"\n      onClick={handleContainerClick}\n    >\n      {children}\n      {isContainerReady && (\n        <Selecto\n          ref={selectoRef}\n          className={styles['cozy-selecto-box']}\n          // eslint-disable-next-line react-hooks/refs\n          container={containerRef.current}\n          dragContainer={window}\n          selectableTargets={['[data-file-id]']}\n          selectByClick={false}\n          selectFromInside={false}\n          hitRate={HIT_RATE}\n          ratio={0}\n          toggleContinueSelect=\"ctrl\" // special key to extend the current selection\n          continueSelect={false} // do not allow to extend the current selection without special key\n          dragCondition={dragCondition}\n          onDragStart={handleDragStart}\n          onDrag={handleDrag}\n          onDragEnd={handleDragEnd}\n          onSelect={handleSelect}\n          onScroll={handleScroll}\n          scrollOptions={\n            resolvedScrollContainer\n              ? {\n                  container: resolvedScrollContainer,\n                  throttleTime: 30,\n                  threshold: 30\n                }\n              : undefined\n          }\n        />\n      )}\n    </div>\n  )\n}\n\nexport default RectangularSelection\n"
  },
  {
    "path": "src/modules/selection/RectangularSelection.styl",
    "content": ".rectangular-selection-container\n    position relative\n\n.cozy-selecto-box\n    background-color var(--actionColorSelected)\n    border 2px solid var(--borderMainColor)\n    border-radius 2px\n"
  },
  {
    "path": "src/modules/selection/SelectionBar.tsx",
    "content": "import React, { useRef, useState } from 'react'\n\nimport ActionsBar from 'cozy-ui/transpiled/react/ActionsBar'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport ShieldCleanIcon from 'cozy-ui/transpiled/react/Icons/ShieldClean'\nimport List from 'cozy-ui/transpiled/react/List'\nimport ListItem from 'cozy-ui/transpiled/react/ListItem'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport Paper from 'cozy-ui/transpiled/react/Paper'\nimport Popover from 'cozy-ui/transpiled/react/Popover'\nimport { useI18n } from 'twake-i18n'\n\nimport {\n  filterActionsByPolicy,\n  hasAnyInfectedFile\n} from '@/modules/actions/policies'\nimport type { DriveAction } from '@/modules/actions/types'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\n\ntype WrappedDriveAction = Record<string, DriveAction>\n\nconst driveActionsToSelectionBarActions = (\n  driveActions: WrappedDriveAction[]\n): WrappedDriveAction[] => {\n  return driveActions.filter(driveAction => {\n    const action = Object.values(driveAction)[0]\n    return (\n      action.displayInSelectionBar === undefined || action.displayInSelectionBar\n    )\n  })\n}\n\nconst SelectionBar: React.FC<{\n  actions?: WrappedDriveAction[]\n  autoClose?: boolean\n}> = ({ actions, autoClose = false }) => {\n  const { t } = useI18n()\n  const { isSelectionBarVisible, hideSelectionBar, selectedItems } =\n    useSelectionContext()\n  const [popoverOpen, setPopoverOpen] = useState(false)\n  const anchorRef = useRef<HTMLButtonElement>(null)\n\n  const handlePopoverOpen = (): void => {\n    setPopoverOpen(true)\n  }\n\n  const handlePopoverClose = (): void => {\n    setPopoverOpen(false)\n  }\n\n  if (isSelectionBarVisible && actions) {\n    const selectedArray = Object.values(selectedItems)\n    let convertedActions = driveActionsToSelectionBarActions(actions)\n    convertedActions = filterActionsByPolicy(convertedActions, selectedArray)\n    const hasInfectedItem = hasAnyInfectedFile(selectedArray)\n\n    let color = 'default'\n    let iconComponent = null\n\n    if (hasInfectedItem) {\n      color = 'error'\n      iconComponent = (): JSX.Element => (\n        <div onMouseEnter={handlePopoverOpen} onMouseLeave={handlePopoverClose}>\n          <IconButton ref={anchorRef}>\n            <Icon color=\"white\" icon=\"info-outlined\" className=\"u-mr-1\" />\n          </IconButton>\n          <Popover\n            open={popoverOpen}\n            anchorEl={anchorRef.current}\n            onClose={handlePopoverClose}\n            anchorOrigin={{\n              vertical: 'top',\n              horizontal: 'left'\n            }}\n            transformOrigin={{\n              vertical: 'bottom',\n              horizontal: 'right'\n            }}\n          >\n            <Paper elevation={8} className=\"u-maw-6\">\n              <List>\n                <ListItem ellipsis={false}>\n                  <ListItemIcon>\n                    <Icon icon={ShieldCleanIcon} color=\"var(--primaryColor)\" />\n                  </ListItemIcon>\n                  <ListItemText\n                    primary={t('antivirus.popover.title')}\n                    secondary={t('antivirus.popover.description')}\n                  />\n                </ListItem>\n              </List>\n            </Paper>\n          </Popover>\n        </div>\n      )\n    }\n\n    return (\n      <ActionsBar\n        actions={convertedActions}\n        docs={selectedArray}\n        onClose={hideSelectionBar}\n        autoClose={autoClose}\n        color={color}\n        IconComponent={iconComponent}\n      />\n    )\n  }\n\n  return null\n}\n\nexport default SelectionBar\n"
  },
  {
    "path": "src/modules/selection/SelectionProvider.d.ts",
    "content": "import { SelectionContextType, SelectionProviderProps } from './types'\n\ndeclare const SelectionProvider: React.FC<SelectionProviderProps>\ndeclare const useSelectionContext: () => SelectionContextType\n\nexport { SelectionProvider, useSelectionContext }\nexport type { SelectionContextType, SelectionProviderProps }\n"
  },
  {
    "path": "src/modules/selection/SelectionProvider.jsx",
    "content": "import React, {\n  createContext,\n  useContext,\n  useMemo,\n  useState,\n  useEffect,\n  useCallback\n} from 'react'\nimport { useLocation } from 'react-router-dom'\n\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\n/**\n * @typedef TSelectionContext\n * @property {Function} showSelectionBar Show the SelectionBar\n * @property {Function} hideSelectionBar Hide the SelectionBar\n * @property {boolean} isSelectionBarVisible Whether the SelectionBar is visible or not\n * @property {Array} selectedItems List of selected items\n * @property {Function} toggleSelectedItem Select an item if it is already selected, otherwise deselect it\n * @property {Function} isItemSelected Find out if an item is selected by its id\n * @property {boolean} isSelectAll Whether all the items are selected or not\n * @property {Function} toggleSelectAllItems Toggle selects all items\n * @property {Function} selectAll Select all items\n * @property {Function} clearSelection Clear all the selected items\n */\n\n/** @type {import('react').Context<TSelectionContext>} */\nconst SelectionContext = createContext()\n\n/**\n * This provider allows you to manage item selection\n */\nconst SelectionProvider = ({ children }) => {\n  const location = useLocation()\n  const [selectedItems, setSelectedItems] = useState({})\n  const [isSelectionBarOpen, setSelectionBarOpen] = useState(false)\n  const [isSelectAll, setIsSelectAll] = useState(false)\n\n  const { highlightedItems, clearItems } = useNewItemHighlightContext()\n\n  const isItemSelected = id => {\n    return selectedItems[id] !== undefined\n  }\n\n  const toggleSelectedItem = item => {\n    if (highlightedItems?.length) {\n      clearItems()\n    }\n\n    if (isItemSelected(item._id)) {\n      const { [item._id]: _, ...stillSelected } = selectedItems\n      setSelectedItems(stillSelected)\n    } else {\n      setSelectedItems({ ...selectedItems, [item._id]: item })\n    }\n  }\n\n  const selectAll = items => {\n    const newSelectedItems = items.reduce((acc, item) => {\n      acc[item._id] = item\n      return acc\n    }, {})\n    setSelectedItems(newSelectedItems)\n    setIsSelectAll(true)\n  }\n\n  const clearSelection = useCallback(() => {\n    setIsSelectAll(false)\n    setSelectedItems({})\n  }, [])\n\n  const toggleSelectAllItems = items => {\n    if (isSelectAll) {\n      clearSelection()\n    } else {\n      selectAll(items)\n    }\n  }\n\n  const showSelectionBar = () => setSelectionBarOpen(true)\n  const hideSelectionBar = useCallback(() => {\n    clearSelection()\n    setSelectionBarOpen(false)\n  }, [clearSelection])\n\n  const isSelectionBarVisible = useMemo(() => {\n    return Object.keys(selectedItems).length !== 0 || isSelectionBarOpen\n  }, [isSelectionBarOpen, selectedItems])\n\n  useEffect(() => {\n    hideSelectionBar()\n  }, [location, hideSelectionBar])\n\n  return (\n    <SelectionContext.Provider\n      value={{\n        showSelectionBar,\n        hideSelectionBar,\n        clearSelection,\n        isSelectionBarVisible,\n        selectedItems: Object.values(selectedItems),\n        toggleSelectedItem,\n        selectAll,\n        isItemSelected,\n        isSelectAll,\n        toggleSelectAllItems,\n        setSelectedItems,\n        setIsSelectAll\n      }}\n    >\n      {children}\n    </SelectionContext.Provider>\n  )\n}\n\nconst useSelectionContext = () => useContext(SelectionContext)\n\nexport { SelectionProvider, useSelectionContext }\n"
  },
  {
    "path": "src/modules/selection/SelectionProvider.spec.jsx",
    "content": "import { render, screen, fireEvent, waitFor } from '@testing-library/react'\nimport React from 'react'\nimport { Provider } from 'react-redux'\nimport {\n  MemoryRouter,\n  Routes,\n  Route,\n  Link,\n  useLocation\n} from 'react-router-dom'\nimport { createStore } from 'redux'\n\nimport { generateFile } from 'test/generate'\n\nimport {\n  SelectionProvider,\n  useSelectionContext\n} from '@/modules/selection/SelectionProvider'\n\njest.mock('modules/upload/NewItemHighlightProvider', () => ({\n  ...jest.requireActual('modules/upload/NewItemHighlightProvider'),\n  useNewItemHighlightContext: () => ({\n    addItems: jest.fn()\n  })\n}))\n\n// Create a mock store for testing\nconst mockStore = createStore(() => ({\n  // Add any state that SelectionProvider needs\n  upload: {\n    queue: [],\n    newItems: []\n  }\n}))\n\nconst SelectionConsumer = ({ items }) => {\n  const {\n    showSelectionBar,\n    hideSelectionBar,\n    isSelectionBarVisible,\n    toggleSelectedItem,\n    isItemSelected\n  } = useSelectionContext()\n  const { pathname } = useLocation()\n\n  return (\n    <>\n      {pathname === '/' && <Link to=\"/other\">Change route</Link>}\n      {isSelectionBarVisible && (\n        <button onClick={hideSelectionBar}>Hide selection bar</button>\n      )}\n      {items.map((item, index) => (\n        <button\n          onClick={() => toggleSelectedItem(item)}\n          key={item.id}\n          data-testid={`item-${index + 1}`}\n        >\n          {`Item ${item.id} ${isItemSelected(item.id) ? 'selected' : ''}`}\n        </button>\n      ))}\n      <button onClick={showSelectionBar}>Show selection bar</button>\n    </>\n  )\n}\n\ndescribe('SelectionProvider', () => {\n  const item1 = generateFile({ i: 1 })\n  const item2 = generateFile({ i: 2 })\n  const item3 = generateFile({ i: 3 })\n  const items = [item1, item2, item3]\n\n  const setup = () => {\n    return render(\n      <Provider store={mockStore}>\n        <MemoryRouter initialEntries={['/']}>\n          <SelectionProvider>\n            <Routes>\n              <Route path=\"/\" element={<SelectionConsumer items={items} />} />\n              <Route\n                path=\"/other\"\n                element={<SelectionConsumer items={items} />}\n              />\n            </Routes>\n          </SelectionProvider>\n        </MemoryRouter>\n      </Provider>\n    )\n  }\n\n  it('show and hide the selection bar', async () => {\n    setup()\n\n    expect(screen.queryByText('Hide selection bar')).toBeNull()\n\n    fireEvent.click(screen.getByText('Show selection bar'))\n    await waitFor(async () => {\n      const hideButton = await screen.findByText('Hide selection bar')\n      expect(hideButton).toBeInTheDocument()\n    })\n\n    fireEvent.click(screen.getByText('Hide selection bar'))\n    expect(screen.queryByText('Hide selection bar')).toBeNull()\n  })\n\n  it('select and deselects item', () => {\n    setup()\n\n    // selecting one item\n    fireEvent.click(screen.getByText('Item file-foobar1'))\n    expect(screen.getByText('Item file-foobar1 selected')).toBeInTheDocument()\n    expect(screen.getByText('Item file-foobar2')).toBeInTheDocument()\n    expect(screen.getByText('Hide selection bar')).toBeInTheDocument()\n\n    // selecting a second item\n    fireEvent.click(screen.getByText('Item file-foobar2'))\n    expect(screen.getByText('Item file-foobar1 selected')).toBeInTheDocument()\n    expect(screen.getByText('Item file-foobar2 selected')).toBeInTheDocument()\n    expect(screen.getByText('Hide selection bar')).toBeInTheDocument()\n\n    // deselecting the first item\n    fireEvent.click(screen.getByText('Item file-foobar1 selected'))\n    expect(screen.getByText('Item file-foobar1')).toBeInTheDocument()\n    expect(screen.getByText('Item file-foobar2 selected')).toBeInTheDocument()\n    expect(screen.getByText('Hide selection bar')).toBeInTheDocument()\n\n    // deselecting the second item\n    fireEvent.click(screen.getByText('Item file-foobar2 selected'))\n    expect(screen.getByText('Item file-foobar1')).toBeInTheDocument()\n    expect(screen.getByText('Item file-foobar2')).toBeInTheDocument()\n    expect(screen.queryByText('Hide selection bar')).toBeNull()\n  })\n\n  it('should deselects items when location changed', async () => {\n    setup()\n\n    // show selection bar\n    fireEvent.click(screen.getByText('Show selection bar'))\n    const hideButton = await screen.findByText('Hide selection bar')\n    expect(hideButton).toBeInTheDocument()\n\n    // selecting all items\n    fireEvent.click(screen.getByText('Item file-foobar1'))\n    fireEvent.click(screen.getByText('Item file-foobar2'))\n    expect(screen.getByText('Item file-foobar1 selected')).toBeInTheDocument()\n    expect(screen.getByText('Item file-foobar2 selected')).toBeInTheDocument()\n    expect(screen.getByText('Hide selection bar')).toBeInTheDocument()\n\n    // change route\n    fireEvent.click(screen.getByText('Change route'))\n\n    // hide selection bar and selecting all items\n    await waitFor(async () => {\n      expect(await screen.findByText('Item file-foobar1')).toBeInTheDocument()\n      expect(await screen.findByText('Item file-foobar2')).toBeInTheDocument()\n      expect(screen.queryByText('Hide selection bar')).toBeNull()\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/selection/types.ts",
    "content": "import { ReactNode } from 'react'\n\nimport { IOCozyFile } from 'cozy-client/types/types'\n\nexport type SelectedItems = Record<string, IOCozyFile>\n\nexport interface SelectionContextType {\n  /** Show the SelectionBar */\n  showSelectionBar: () => void\n\n  /** Hide the SelectionBar */\n  hideSelectionBar: () => void\n\n  /** Clear all the selected items */\n  clearSelection: () => void\n\n  /** Whether the SelectionBar is visible or not */\n  isSelectionBarVisible: boolean\n\n  /** List of selected items as an array */\n  selectedItems: IOCozyFile[]\n\n  /** Select an item if it is not selected, otherwise deselect it */\n  toggleSelectedItem: (item: IOCozyFile) => void\n\n  /** Select all items */\n  selectAll: (items: IOCozyFile[]) => void\n\n  /** Find out if an item is selected by its id */\n  isItemSelected: (id: string) => boolean\n\n  /** Whether all the items are selected or not */\n  isSelectAll: boolean\n\n  /** Toggle selects all items */\n  toggleSelectAllItems: (items: IOCozyFile[]) => void\n\n  /** Set selected items directly (used internally) */\n  setSelectedItems: (\n    items: SelectedItems | ((prev: SelectedItems) => SelectedItems)\n  ) => void\n\n  /** Set select all status */\n  setIsSelectAll: (isSelectAll: boolean) => void\n}\n\nexport interface SelectionProviderProps {\n  children: ReactNode\n}\n"
  },
  {
    "path": "src/modules/selectors.js",
    "content": "import maxBy from 'lodash/maxBy'\n\nimport { getDocumentFromState } from 'cozy-client/dist/store'\n\nimport { DOCTYPE_FILES } from '@/lib/doctypes'\nimport { getMirrorQueryId, parseFolderQueryId } from '@/lib/queries'\n\nexport const getFolderContentQueries = (rootState, folderId) => {\n  const queries = rootState.cozy.queries\n  const folderContentQueries = Object.entries(queries)\n    .filter(([queryId]) => {\n      const parsed = parseFolderQueryId(queryId)\n      if (!parsed) {\n        return false\n      }\n      const { folderId: queryFolderId } = parsed\n      if (queryFolderId !== folderId) {\n        return false\n      }\n      return true\n    })\n    .map(x => x[1])\n  return folderContentQueries\n}\n\nexport const getLatestFolderQueryResults = (rootState, folderId) => {\n  const folderContentQueries = getFolderContentQueries(rootState, folderId)\n  if (folderContentQueries.length > 0) {\n    const mostRecentQueryResults =\n      maxBy(folderContentQueries, x => x.lastUpdate) || folderContentQueries[0]\n    const otherQueryId = getMirrorQueryId(mostRecentQueryResults.id)\n    const otherQueryResults = rootState.cozy.queries[otherQueryId]\n    return [mostRecentQueryResults, otherQueryResults]\n  }\n  return []\n}\n\nexport const getFolderContent = (rootState, folderId) => {\n  const results = getLatestFolderQueryResults(rootState, folderId)\n  if (results.length > 0) {\n    const [mostRecentQueryResults, otherQueryResults] = results\n    const allContent = mostRecentQueryResults.data.concat(\n      otherQueryResults ? otherQueryResults.data : []\n    )\n    return allContent.map(fileId => {\n      return getDocumentFromState(rootState, DOCTYPE_FILES, fileId)\n    })\n  } else {\n    return null\n  }\n}\n"
  },
  {
    "path": "src/modules/selectors.spec.js",
    "content": "import { getFolderContent } from './selectors'\nimport {\n  setupFolderContent,\n  setupStoreAndClient,\n  mockCozyClientRequestQuery\n} from 'test/setup'\n\njest.mock('modules/navigation/AppRoute', () => ({ routes: [] }))\n\nmockCozyClientRequestQuery('folderid123456')\n\ndescribe('getFolderContent', () => {\n  it('should return an empty list if queries have not been loaded', () => {\n    const folderId = 'folderid123456'\n    const { store } = setupStoreAndClient()\n    const state = store.getState()\n    const files = getFolderContent(state, folderId)\n    expect(files).toEqual(null)\n  })\n\n  it('should return content from cozy client queries', async () => {\n    const folderId = 'folderid123456'\n    const { store } = await setupFolderContent({ folderId })\n    const state = store.getState()\n    const files = getFolderContent(state, folderId)\n    expect(files.length).toBe(13)\n  })\n})\n"
  },
  {
    "path": "src/modules/services/components/Embeder.jsx",
    "content": "import React from 'react'\nimport { HashRouter, Routes, Route } from 'react-router-dom'\n\nimport Sprite from 'cozy-ui/transpiled/react/Icon/Sprite'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\nimport withBreakpoints from 'cozy-ui/transpiled/react/helpers/withBreakpoints'\n\nimport FileOpenerExternal from '@/modules/viewer/FileOpenerExternal'\nimport OnlyOfficeView from '@/modules/views/OnlyOffice'\nimport { isOfficeEnabled } from '@/modules/views/OnlyOffice/helpers'\n\nclass Embeder extends React.Component {\n  constructor(props) {\n    super(props)\n\n    this.state = {\n      loading: true\n    }\n  }\n\n  componentDidMount() {\n    this.fetchFileUrl()\n  }\n\n  async fetchFileUrl() {\n    const { service } = this.props\n\n    try {\n      const { id } = service.getData()\n      this.setState({ fileId: id, loading: false })\n    } catch (error) {\n      this.setState({ error, loading: false })\n    }\n  }\n\n  render() {\n    const {\n      service,\n      breakpoints: { isDesktop }\n    } = this.props\n    return (\n      <div>\n        {this.state.loading && (\n          <Spinner size=\"xxlarge\" middle noMargin color=\"white\" />\n        )}\n        {this.state.error && (\n          <pre className=\"u-error\">{this.state.error.toString()}</pre>\n        )}\n        {this.state.fileId && (\n          <HashRouter>\n            <Routes>\n              <Route\n                path=\"/\"\n                element={\n                  <FileOpenerExternal\n                    service={service}\n                    fileId={this.state.fileId}\n                  />\n                }\n              />\n              {isOfficeEnabled(isDesktop) && (\n                <Route path=\"onlyoffice/:fileId\" element={<OnlyOfficeView />} />\n              )}\n            </Routes>\n          </HashRouter>\n        )}\n        <Sprite />\n      </div>\n    )\n  }\n}\n\nexport default withBreakpoints()(Embeder)\n"
  },
  {
    "path": "src/modules/services/components/IntentHandler.jsx",
    "content": "import React, { useEffect, useState } from 'react'\n\nimport { useClient } from 'cozy-client'\nimport Intents from 'cozy-interapp'\nimport logger from 'cozy-logger'\n\nimport Embeder from './Embeder'\n\nconst IntentHandler = ({ intentId }) => {\n  const client = useClient()\n\n  const [state, setState] = useState({\n    component: null,\n    service: null,\n    intent: null\n  })\n\n  const ServiceComponent = state.component\n\n  useEffect(() => {\n    const startService = async () => {\n      let component\n      let service\n      let intent\n      try {\n        const intents = new Intents({ client })\n        service = await intents.createService(intentId, window)\n        intent = service.getIntent()\n\n        if (\n          intent.attributes.action === 'OPEN' &&\n          intent.attributes.type === 'io.cozy.files'\n        ) {\n          component = Embeder\n        }\n\n        setState({\n          component,\n          service,\n          intent\n        })\n      } catch (error) {\n        logger.error(error)\n        service.throw(error)\n      }\n    }\n\n    startService()\n  }, [client, intentId])\n\n  return ServiceComponent ? (\n    <ServiceComponent service={state.service} intent={state.intent} />\n  ) : (\n    <div className=\"u-w-100 u-bg-charcoalGrey\" />\n  )\n}\n\nexport default IntentHandler\n"
  },
  {
    "path": "src/modules/services/index.jsx",
    "content": "import IntentHandler from './components/IntentHandler'\nexport default IntentHandler\n"
  },
  {
    "path": "src/modules/services/services.styl",
    "content": ".fullscreen\n    position absolute\n    top 0\n    left 0\n    right 0\n    bottom 0\n    width 100%\n    height 100%\n"
  },
  {
    "path": "src/modules/shareddrives/components/SharedDriveBreadcrumb.jsx",
    "content": "import React, { useCallback, useMemo } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useQuery } from 'cozy-client'\nimport { useI18n } from 'twake-i18n'\n\nimport { SHARED_DRIVES_DIR_ID } from '@/constants/config.js'\nimport { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'\nimport { useBreadcrumbPath } from '@/modules/breadcrumb/hooks/useBreadcrumbPath.jsx'\nimport { buildSharedDriveIdQuery } from '@/queries'\n\nconst SharedDriveBreadcrumb = ({ driveId, folderId }) => {\n  const { t } = useI18n()\n  const navigate = useNavigate()\n\n  const sharedDriveQuery = buildSharedDriveIdQuery({ driveId })\n  const { data: sharedDrive } = useQuery(\n    sharedDriveQuery.definition,\n    sharedDriveQuery.options\n  )\n\n  const rootBreadcrumbPath = useMemo(\n    () => ({\n      id: sharedDrive?.rules?.[0]?.values?.[0],\n      name: sharedDrive?.description\n    }),\n    [sharedDrive]\n  )\n\n  const path = useBreadcrumbPath({\n    currentFolderId: folderId,\n    rootBreadcrumbPath,\n    driveId\n  })\n\n  const handleBreadcrumbClick = useCallback(\n    ({ id }) => {\n      if (id === SHARED_DRIVES_DIR_ID) {\n        navigate(`/folder/${SHARED_DRIVES_DIR_ID}`)\n        return\n      }\n      navigate(`/shareddrive/${driveId}/${id}`)\n    },\n    [driveId, navigate]\n  )\n\n  return (\n    <Breadcrumb\n      path={[\n        {\n          id: SHARED_DRIVES_DIR_ID,\n          name: t('breadcrumb.title_shared_drives')\n        },\n        ...path\n      ]}\n      onBreadcrumbClick={handleBreadcrumbClick}\n      opening={false}\n    />\n  )\n}\n\nexport { SharedDriveBreadcrumb }\n"
  },
  {
    "path": "src/modules/shareddrives/components/SharedDriveFolderBody.jsx",
    "content": "import React from 'react'\nimport { useDispatch } from 'react-redux'\nimport { useNavigate, useLocation, useParams } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport { useVaultClient } from 'cozy-keys-lib'\nimport { useSharingContext } from 'cozy-sharing'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { useModalContext } from '@/lib/ModalContext'\nimport {\n  download,\n  infos,\n  versions,\n  rename,\n  trash,\n  hr,\n  summariseByAI\n} from '@/modules/actions'\nimport { duplicateTo } from '@/modules/actions/components/duplicateTo'\nimport { moveTo } from '@/modules/actions/components/moveTo'\nimport { FolderBody } from '@/modules/folder/components/FolderBody'\n\nconst SharedDriveFolderBody = ({\n  folderId,\n  queryResults,\n  refreshFolderContent\n}) => {\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n  const client = useClient()\n  const vaultClient = useVaultClient()\n  const { driveId } = useParams()\n  const { t } = useI18n()\n  const { isOwner, byDocId, hasWriteAccess, refresh } = useSharingContext()\n  const { isMobile } = useBreakpoints()\n  const { showAlert } = useAlert()\n  const dispatch = useDispatch()\n  const { pushModal, popModal } = useModalContext()\n\n  const canWriteToCurrentFolder = hasWriteAccess(folderId, driveId)\n\n  const actionsOptions = {\n    client,\n    t,\n    vaultClient,\n    pathname,\n    isOwner,\n    isMobile,\n    driveId,\n    hasWriteAccess: canWriteToCurrentFolder,\n    byDocId,\n    dispatch,\n    canMove: canWriteToCurrentFolder,\n    canDuplicate: canWriteToCurrentFolder,\n    navigate,\n    showAlert,\n    pushModal,\n    popModal,\n    refresh\n  }\n  const actions = makeActions(\n    [\n      download,\n      hr,\n      summariseByAI,\n      hr,\n      rename,\n      moveTo,\n      duplicateTo,\n      infos,\n      hr,\n      versions,\n      hr,\n      trash\n    ],\n    actionsOptions\n  )\n\n  return (\n    <FolderBody\n      folderId={folderId}\n      queryResults={queryResults}\n      actions={actions}\n      withFilePath={false}\n      driveId={driveId}\n      refreshFolderContent={refreshFolderContent}\n    />\n  )\n}\n\nexport { SharedDriveFolderBody }\n"
  },
  {
    "path": "src/modules/shareddrives/components/actions/leaveSharedDrive.js",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport LogoutIcon from 'cozy-ui/transpiled/react/Icons/Logout'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'\n\n// Only for sharing tabs\nexport const leaveSharedDrive = ({ client, showAlert, t }) => {\n  const label = t('toolbar.menu_leave_shared_drive')\n  const icon = LogoutIcon\n\n  return {\n    name: 'leaveSharedDrive',\n    label: label,\n    icon,\n    displayCondition: docs => {\n      return docs.length === 1 && isFromSharedDriveRecipient(docs[0])\n    },\n    action: async docs => {\n      const sharedDriveId = docs[0].driveId\n\n      await client\n        .collection('io.cozy.sharings')\n        .revokeSelf({ _id: sharedDriveId })\n\n      showAlert({\n        message: t('Files.share.revokeSelf.success'),\n        severity: 'success'\n      })\n    },\n    Component: forwardRef(function deleteSharedDrive(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} className=\"u-error\" />\n          </ListItemIcon>\n          <ListItemText primary={label} className=\"u-error\" />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n"
  },
  {
    "path": "src/modules/shareddrives/components/actions/shareSharedDrive.js",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { navigateToModal } from '@/modules/actions/helpers'\nimport { isFromSharedDriveRecipient } from '@/modules/shareddrives/helpers'\n\n// Only for sharing tabs\nexport const shareSharedDrive = ({ navigate, t }) => {\n  const label = t('Files.share.cta')\n  const icon = ShareIcon\n\n  return {\n    name: 'shareSharedDrive',\n    label: label,\n    icon,\n    displayCondition: docs => {\n      return docs.length === 1 && isFromSharedDriveRecipient(docs[0])\n    },\n    action: docs => {\n      const folderId = docs[0]._id\n      const driveId = docs[0].driveId\n\n      navigateToModal({\n        navigate,\n        pathname: `/shareddrive/${driveId}/${folderId}`,\n        files: docs,\n        path: 'share'\n      })\n    },\n    Component: forwardRef(function ShareSharedDrive(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} />\n          </ListItemIcon>\n          <ListItemText primary={label} />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n"
  },
  {
    "path": "src/modules/shareddrives/helpers.ts",
    "content": "import CozyClient, { generateWebLink } from 'cozy-client'\nimport { IOCozyFile } from 'cozy-client/types/types'\n\n// Temporary type, need to be completed and then put in cozy-client\nexport interface SharedDrive {\n  _id: string\n  description: string\n  rules: Rule[]\n  owner?: boolean\n}\n\nexport interface Rule {\n  title: string\n  values: string[]\n}\n\n/**\n * Extract the sharing id from a file/folder relationships.referenced_by\n * Returns undefined if not referenced by a sharing\n */\nexport const getSharingIdFromRelationships = (doc: {\n  relationships?: {\n    referenced_by?: { data?: { id: string; type: string }[] }\n  }\n}): string | undefined =>\n  doc.relationships?.referenced_by?.data?.find(\n    ref => ref.type === 'io.cozy.sharings'\n  )?.id\n\nexport const getFolderIdFromSharing = (\n  sharing: SharedDrive\n): string | undefined => {\n  try {\n    return sharing.rules[0].values[0]\n  } catch {\n    return undefined\n  }\n}\n\nexport const isFromSharedDriveRecipient = (folder: IOCozyFile): boolean =>\n  folder && Boolean(folder.driveId)\n\nexport const makeSharedDriveNoteReturnUrl = (\n  client: CozyClient,\n  file: IOCozyFile\n): string => {\n  return generateWebLink({\n    slug: 'drive',\n    searchParams: [],\n    // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access\n    cozyUrl: client.getStackClient().uri as string,\n    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment\n    subDomainType: client.getInstanceOptions().subdomain,\n    pathname: '',\n    hash: `/shareddrive/${file.driveId!}/${file.dir_id}`\n  })\n}\n"
  },
  {
    "path": "src/modules/shareddrives/hooks/useQueryMultipleSharedDriveFolders.tsx",
    "content": "import { useCallback, useEffect, useMemo, useState } from 'react'\n\nimport { useClient } from 'cozy-client'\nimport type { IOCozyFile } from 'cozy-client/types/types'\n\nimport { buildSharedDriveFolderQuery } from '@/queries'\n\ninterface UseQueryMultipleSharedDriveFoldersProps {\n  driveId: string\n  folderIds: string[]\n}\n\ninterface SharedDriveResult {\n  data: IOCozyFile | null\n}\n\ninterface SharedDriveFolderReturn {\n  sharedDriveResults: IOCozyFile[] | null\n}\n\nconst useQueryMultipleSharedDriveFolders = ({\n  driveId,\n  folderIds\n}: UseQueryMultipleSharedDriveFoldersProps): SharedDriveFolderReturn => {\n  const client = useClient()\n\n  const [sharedDriveResults, setSharedDriveResults] = useState<\n    SharedDriveFolderReturn['sharedDriveResults']\n  >([])\n\n  const sharedDriveQueries = useMemo(\n    () =>\n      folderIds.map(folderId =>\n        buildSharedDriveFolderQuery({\n          driveId,\n          folderId\n        })\n      ),\n    [driveId, folderIds]\n  )\n\n  const fetchSharedDriveResults = useCallback(async () => {\n    const results = await Promise.all(\n      sharedDriveQueries.map(async query => {\n        return client?.query(\n          query.definition(),\n          query.options\n        ) as Promise<SharedDriveResult>\n      })\n    )\n\n    setSharedDriveResults(\n      results.map(\n        (result: SharedDriveResult) => result.data\n      ) as SharedDriveFolderReturn['sharedDriveResults']\n    )\n  }, [client, sharedDriveQueries])\n\n  useEffect(() => {\n    if (client) {\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      void fetchSharedDriveResults()\n    }\n  }, [client, fetchSharedDriveResults])\n\n  return {\n    sharedDriveResults\n  }\n}\n\nexport { useQueryMultipleSharedDriveFolders }\n"
  },
  {
    "path": "src/modules/shareddrives/hooks/useSharedDriveFolder.spec.jsx",
    "content": "import { renderHook, act, waitFor } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\nimport CozyRealtime from 'cozy-realtime'\n\nimport { useSharedDriveFolder } from './useSharedDriveFolder'\nimport AppLike from 'test/components/AppLike'\n\nimport logger from '@/lib/logger'\n\njest.mock('cozy-realtime', () => {\n  return jest.fn().mockImplementation(() => ({\n    subscribe: jest.fn(),\n    stop: jest.fn()\n  }))\n})\n\njest.mock('lodash/debounce', () =>\n  jest.fn(fn => {\n    const immediate = (...args) => fn(...args)\n    immediate.cancel = jest.fn()\n    return immediate\n  })\n)\n\njest.mock('@/lib/logger', () => ({\n  __esModule: true,\n  default: {\n    error: jest.fn()\n  }\n}))\n\ndescribe('useSharedDriveFolder', () => {\n  const mockDriveId = 'drive-id-1'\n  const mockFolderId = 'folder-id-1'\n  const mockData = [\n    { _id: '1', name: 'file-1.txt', type: 'file' },\n    { _id: '2', name: 'file-2.txt', type: 'file' }\n  ]\n\n  const makeMockClient = statByIdFn => {\n    const mockClient = createMockClient({})\n    mockClient.getStackClient = () => ({\n      collection: () => ({\n        statById: statByIdFn\n      })\n    })\n    return mockClient\n  }\n\n  const setup = mockClient => {\n    const wrapper = ({ children }) => (\n      <AppLike client={mockClient}>{children}</AppLike>\n    )\n\n    return renderHook(\n      () =>\n        useSharedDriveFolder({ driveId: mockDriveId, folderId: mockFolderId }),\n      { wrapper }\n    )\n  }\n\n  afterEach(() => {\n    jest.clearAllMocks()\n  })\n\n  it('should fetch initial data', async () => {\n    const statByIdMock = jest.fn().mockResolvedValue({\n      included: mockData,\n      links: {}\n    })\n    const mockClient = makeMockClient(statByIdMock)\n\n    const { result } = setup(mockClient)\n\n    expect(result.current.sharedDriveResult.data).toBeUndefined()\n\n    await waitFor(() => {\n      expect(result.current.sharedDriveResult.included).toEqual(mockData)\n    })\n\n    expect(result.current.hasMore).toBe(false)\n  })\n\n  it('should indicate when there is more data to fetch', async () => {\n    const cursor = 'next-page-cursor'\n    const statByIdMock = jest.fn().mockResolvedValue({\n      included: mockData,\n      links: {\n        next: `/relative/link?page[cursor]=${cursor}&other=params`\n      }\n    })\n    const mockClient = makeMockClient(statByIdMock)\n\n    const { result } = setup(mockClient)\n\n    await waitFor(() => {\n      expect(result.current.hasMore).toBe(true)\n    })\n  })\n\n  it('should fetch more data when fetchMore is called', async () => {\n    const cursor = 'next-page-cursor'\n    const nextPageData = [\n      { _id: '3', name: 'file-3.txt', type: 'file' },\n      { _id: '4', name: 'file-4.txt', type: 'file' }\n    ]\n\n    const statByIdMock = jest\n      .fn()\n      .mockResolvedValueOnce({\n        included: mockData,\n        links: { next: `/relative/link?page[cursor]=${cursor}` }\n      })\n      .mockResolvedValueOnce({\n        included: nextPageData,\n        links: {}\n      })\n    const mockClient = makeMockClient(statByIdMock)\n\n    const { result } = setup(mockClient)\n\n    await waitFor(() => expect(result.current.hasMore).toBe(true))\n\n    await act(() => result.current.fetchMore())\n\n    expect(statByIdMock).toHaveBeenLastCalledWith(mockFolderId, {\n      'page[cursor]': cursor,\n      'page[limit]': 100\n    })\n\n    await waitFor(() => {\n      expect(result.current.sharedDriveResult.included).toEqual([\n        ...mockData,\n        ...nextPageData\n      ])\n    })\n\n    expect(result.current.hasMore).toBe(false)\n  })\n\n  it('should handle empty response', async () => {\n    const statByIdMock = jest.fn().mockResolvedValue({\n      included: [],\n      links: {}\n    })\n    const mockClient = makeMockClient(statByIdMock)\n\n    const { result } = setup(mockClient)\n\n    await waitFor(() => {\n      expect(result.current.sharedDriveResult.included).toEqual([])\n    })\n\n    expect(result.current.hasMore).toBe(false)\n  })\n\n  it('should handle errors during fetchMore and keep existing data', async () => {\n    const cursor = 'next-page-cursor'\n    const statByIdMock = jest\n      .fn()\n      .mockResolvedValueOnce({\n        included: mockData,\n        links: { next: `/relative/link?page[cursor]=${cursor}` }\n      })\n      .mockRejectedValueOnce(new Error('Network error'))\n    const mockClient = makeMockClient(statByIdMock)\n\n    const { result } = setup(mockClient)\n\n    await waitFor(() => expect(result.current.hasMore).toBe(true))\n\n    await act(() => result.current.fetchMore())\n\n    expect(result.current.sharedDriveResult.included).toEqual(mockData)\n    expect(result.current.hasMore).toBe(true)\n    expect(logger.error).toHaveBeenCalledWith(\n      'Error fetching more shared drive files:',\n      expect.any(Error)\n    )\n  })\n\n  it('should not fetch more if already fetching', async () => {\n    const cursor = 'next-page-cursor'\n    const statByIdMock = jest.fn().mockResolvedValue({\n      included: mockData,\n      links: { next: `/relative/link?page[cursor]=${cursor}` }\n    })\n    const mockClient = makeMockClient(statByIdMock)\n\n    const { result } = setup(mockClient)\n\n    await waitFor(() => expect(result.current.hasMore).toBe(true))\n\n    await act(async () => {\n      const fetchPromise = result.current.fetchMore()\n      await result.current.fetchMore()\n      await fetchPromise\n    })\n\n    expect(statByIdMock).toHaveBeenCalledTimes(2)\n  })\n\n  describe('realtime re-fetch', () => {\n    let triggerRealtimeEvent\n\n    beforeEach(() => {\n      CozyRealtime.mockImplementation(() => ({\n        subscribe: jest.fn((_event, _doctype, callback) => {\n          triggerRealtimeEvent = callback\n        }),\n        stop: jest.fn()\n      }))\n    })\n\n    it('should re-fetch only page 1 when realtime fires before any fetchMore', async () => {\n      const cursor = 'cursor-page-2'\n      const page1 = [{ _id: '1', name: 'file-1.txt', type: 'file' }]\n      const refreshedPage1 = [\n        { _id: '1', name: 'file-1-renamed.txt', type: 'file' }\n      ]\n\n      const statByIdMock = jest\n        .fn()\n        .mockResolvedValueOnce({\n          included: page1,\n          links: { next: `/link?page[cursor]=${cursor}` }\n        })\n        .mockResolvedValueOnce({\n          included: refreshedPage1,\n          links: { next: `/link?page[cursor]=${cursor}` }\n        })\n      const mockClient = makeMockClient(statByIdMock)\n      const { result } = setup(mockClient)\n\n      await waitFor(() =>\n        expect(result.current.sharedDriveResult.included).toEqual(page1)\n      )\n      expect(statByIdMock).toHaveBeenCalledTimes(1)\n\n      await act(async () => {\n        triggerRealtimeEvent()\n      })\n\n      await waitFor(() =>\n        expect(result.current.sharedDriveResult.included).toEqual(\n          refreshedPage1\n        )\n      )\n      // 1 initial + 1 re-fetch (page 1 only)\n      expect(statByIdMock).toHaveBeenCalledTimes(2)\n    })\n\n    it('should re-fetch all loaded pages when realtime fires after fetchMore', async () => {\n      const cursor1 = 'cursor-page-2'\n      const cursor2 = 'cursor-page-3'\n\n      const page1 = [{ _id: '1', name: 'file-1.txt', type: 'file' }]\n      const page2 = [{ _id: '2', name: 'file-2.txt', type: 'file' }]\n      const page3 = [{ _id: '3', name: 'file-3.txt', type: 'file' }]\n      const refreshedPage1 = [\n        { _id: '1', name: 'file-1-renamed.txt', type: 'file' }\n      ]\n      const refreshedPage2 = [{ _id: '2', name: 'file-2.txt', type: 'file' }]\n      const refreshedPage3 = [{ _id: '3', name: 'file-3.txt', type: 'file' }]\n\n      const statByIdMock = jest\n        .fn()\n        // Initial fetch: page 1\n        .mockResolvedValueOnce({\n          included: page1,\n          links: { next: `/link?page[cursor]=${cursor1}` }\n        })\n        // fetchMore: page 2\n        .mockResolvedValueOnce({\n          included: page2,\n          links: { next: `/link?page[cursor]=${cursor2}` }\n        })\n        // fetchMore: page 3\n        .mockResolvedValueOnce({ included: page3, links: {} })\n        // Realtime re-fetch: page 1\n        .mockResolvedValueOnce({\n          included: refreshedPage1,\n          links: { next: `/link?page[cursor]=${cursor1}` }\n        })\n        // Realtime re-fetch: page 2\n        .mockResolvedValueOnce({\n          included: refreshedPage2,\n          links: { next: `/link?page[cursor]=${cursor2}` }\n        })\n        // Realtime re-fetch: page 3\n        .mockResolvedValueOnce({ included: refreshedPage3, links: {} })\n\n      const mockClient = makeMockClient(statByIdMock)\n      const { result } = setup(mockClient)\n\n      await waitFor(() => expect(result.current.hasMore).toBe(true))\n\n      await act(() => result.current.fetchMore())\n      await act(() => result.current.fetchMore())\n\n      await waitFor(() =>\n        expect(result.current.sharedDriveResult.included).toEqual([\n          ...page1,\n          ...page2,\n          ...page3\n        ])\n      )\n      expect(statByIdMock).toHaveBeenCalledTimes(3)\n\n      await act(async () => {\n        triggerRealtimeEvent()\n      })\n\n      await waitFor(() =>\n        expect(result.current.sharedDriveResult.included).toEqual([\n          ...refreshedPage1,\n          ...refreshedPage2,\n          ...refreshedPage3\n        ])\n      )\n      // 3 initial + 3 re-fetch (all pages)\n      expect(statByIdMock).toHaveBeenCalledTimes(6)\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/shareddrives/hooks/useSharedDriveFolder.tsx",
    "content": "import debounce from 'lodash/debounce'\nimport { useState, useEffect, useMemo, useCallback, useRef } from 'react'\n\nimport { useClient } from 'cozy-client'\nimport type { IOCozyFile } from 'cozy-client/types/types'\nimport CozyRealtime from 'cozy-realtime'\n\nimport logger from '@/lib/logger'\nimport {\n  paginatedStatById,\n  type PaginatedStatByIdResult\n} from '@/modules/shareddrives/hooks/useSharedDriveFolderHelpers'\nimport { buildSharedDriveFolderQuery } from '@/queries'\nimport type { QueryConfig } from '@/queries'\n\ninterface SharedDriveFolderProps {\n  driveId: string\n  folderId: string\n}\n\ninterface SharedDriveFolderReturn {\n  // FIXME: We should use useQuery hook here but it doesn't allow to get included data\n  // See https://github.com/cozy/cozy-client/issues/1620\n  sharedDriveQuery: QueryConfig\n  sharedDriveResult: {\n    data?: IOCozyFile[] | null\n    included?: IOCozyFile[] | null\n  }\n  fetchStatus: 'loading' | 'loaded' | 'failed'\n  hasMore: boolean\n  fetchMore: () => Promise<void>\n}\n\nconst useSharedDriveFolder = ({\n  driveId,\n  folderId\n}: SharedDriveFolderProps): SharedDriveFolderReturn => {\n  const client = useClient()\n  const [sharedDriveResult, setSharedDriveResult] = useState<\n    SharedDriveFolderReturn['sharedDriveResult']\n  >({ data: undefined })\n  const [fetchStatus, setFetchStatus] =\n    useState<SharedDriveFolderReturn['fetchStatus']>('loading')\n  const [nextCursor, setNextCursor] = useState<string | null>(null)\n  const nextCursorRef = useRef<string | null>(null)\n  const isFetchingMore = useRef(false)\n  const fetchGeneration = useRef(0)\n  const loadedPagesCount = useRef(0)\n\n  const sharedDriveQuery = useMemo(\n    () =>\n      buildSharedDriveFolderQuery({\n        driveId,\n        folderId\n      }),\n    [driveId, folderId]\n  )\n\n  const statById = useMemo(\n    () => paginatedStatById(client, driveId),\n    [client, driveId]\n  )\n\n  useEffect(() => {\n    const fetchSharedDriveFolder = async (pagesToLoad = 1): Promise<void> => {\n      fetchGeneration.current += 1\n      const currentGeneration = fetchGeneration.current\n\n      setSharedDriveResult({ data: undefined, included: undefined })\n      setFetchStatus('loading')\n      nextCursorRef.current = null\n      setNextCursor(null)\n      loadedPagesCount.current = 0\n\n      try {\n        let allIncluded: IOCozyFile[] = []\n        let cursor: string | null = null\n\n        for (let page = 0; page < pagesToLoad; page++) {\n          const result: PaginatedStatByIdResult = await statById(\n            folderId,\n            cursor\n          )\n          allIncluded = [...allIncluded, ...(result.included ?? [])]\n          cursor = result.nextCursor\n          if (!result.nextCursor) break\n        }\n\n        if (fetchGeneration.current === currentGeneration) {\n          setSharedDriveResult({ included: allIncluded })\n          setFetchStatus('loaded')\n          nextCursorRef.current = cursor\n          setNextCursor(cursor)\n          loadedPagesCount.current = pagesToLoad\n        }\n      } catch (error) {\n        logger.error('Error fetching shared drive folder:', error)\n        if (fetchGeneration.current === currentGeneration) {\n          setSharedDriveResult({ data: undefined, included: undefined })\n          setFetchStatus('failed')\n          nextCursorRef.current = null\n          setNextCursor(null)\n        }\n      }\n    }\n\n    if (client && driveId && folderId) {\n      void fetchSharedDriveFolder()\n    }\n\n    const debouncedFetch = debounce(() => {\n      void fetchSharedDriveFolder(Math.max(1, loadedPagesCount.current))\n    }, 500)\n\n    let realtime: CozyRealtime | undefined\n    if (client && driveId) {\n      realtime = new CozyRealtime({ client, sharedDriveId: driveId })\n      realtime.subscribe('updated', 'io.cozy.files', debouncedFetch)\n      realtime.subscribe('created', 'io.cozy.files', debouncedFetch)\n      realtime.subscribe('deleted', 'io.cozy.files', debouncedFetch)\n    }\n\n    return (): void => {\n      if (realtime) {\n        realtime.stop()\n      }\n      debouncedFetch.cancel()\n    }\n  }, [client, driveId, folderId, statById])\n\n  const fetchMore = useCallback(async (): Promise<void> => {\n    if (isFetchingMore.current || !nextCursorRef.current || !client) return\n\n    isFetchingMore.current = true\n    const currentGeneration = fetchGeneration.current\n\n    try {\n      const { included, nextCursor: cursor } = await statById(\n        folderId,\n        nextCursorRef.current\n      )\n\n      if (fetchGeneration.current !== currentGeneration) return\n\n      setSharedDriveResult(prev => ({\n        ...prev,\n\n        included: [...(prev.included ?? []), ...(included ?? [])]\n      }))\n      nextCursorRef.current = cursor\n      setNextCursor(cursor)\n      loadedPagesCount.current += 1\n    } catch (error) {\n      logger.error('Error fetching more shared drive files:', error)\n    } finally {\n      isFetchingMore.current = false\n    }\n  }, [client, folderId, statById])\n\n  const hasMore = !!nextCursor\n\n  return {\n    sharedDriveQuery,\n    sharedDriveResult,\n    fetchStatus,\n    hasMore,\n    fetchMore\n  }\n}\n\nexport { useSharedDriveFolder }\n"
  },
  {
    "path": "src/modules/shareddrives/hooks/useSharedDriveFolderHelpers.ts",
    "content": "import CozyClient from 'cozy-client'\nimport type { IOCozyFile } from 'cozy-client/types/types'\n\nconst PAGE_LIMIT = 100\n\ninterface StatByIdLinks {\n  next?: string\n}\n\ninterface StatByIdResult {\n  included: IOCozyFile[]\n  links?: StatByIdLinks\n}\n\ninterface TypedFileCollection {\n  statById: (\n    id: string,\n    opts: Record<string, string | number>\n  ) => Promise<StatByIdResult>\n}\n\nexport interface PaginatedStatByIdResult {\n  included: IOCozyFile[]\n  nextCursor: string | null\n}\n\nexport const paginatedStatById =\n  (client: CozyClient, driveId: string) =>\n  async (\n    folderId: string,\n    cursor: string | null = null\n  ): Promise<PaginatedStatByIdResult> => {\n    const collection = client.collection('io.cozy.files', {\n      driveId\n    }) as unknown as TypedFileCollection\n\n    const { included = [], links } = await collection.statById(folderId, {\n      ...(cursor ? { 'page[cursor]': cursor } : {}),\n      'page[limit]': PAGE_LIMIT\n    })\n\n    let nextCursor: string | null = null\n    if (links?.next) {\n      try {\n        const queryString = links.next.split('?')[1]\n        if (queryString) {\n          const params = new URLSearchParams(queryString)\n          nextCursor = params.get('page[cursor]')\n        }\n      } catch {\n        nextCursor = null\n      }\n    }\n\n    return { included, nextCursor }\n  }\n"
  },
  {
    "path": "src/modules/shareddrives/hooks/useSharedDrives.js",
    "content": "import { useState, useEffect } from 'react'\n\nimport { useClient, useQuery } from 'cozy-client'\n\nimport { DEFAULT_SORT } from '@/config/sort'\nimport { SHARED_DRIVES_DIR_ID } from '@/constants/config'\nimport { buildDriveQuery } from '@/queries'\n\nexport const useSharedDrives = () => {\n  const client = useClient()\n  const [isLoading, setIsLoading] = useState(false)\n  const [isLoaded, setIsLoaded] = useState(false)\n  const [sharedDrives, setSharedDrives] = useState([])\n\n  const folderQuery = buildDriveQuery({\n    currentFolderId: SHARED_DRIVES_DIR_ID,\n    type: 'directory',\n    sortAttribute: DEFAULT_SORT.attribute,\n    sortOrder: DEFAULT_SORT.order\n  })\n  const { lastUpdate } = useQuery(folderQuery.definition, folderQuery.options)\n\n  useEffect(() => {\n    let isCancelled = false\n\n    const fetchSharedDrives = async () => {\n      setIsLoading(true)\n      try {\n        const { data: sharedDrives } = await client\n          .collection('io.cozy.sharings')\n          .fetchSharedDrives()\n\n        if (!isCancelled) {\n          setSharedDrives(sharedDrives)\n        }\n      } finally {\n        if (!isCancelled) {\n          setIsLoading(false)\n          setIsLoaded(true)\n        }\n      }\n    }\n\n    void fetchSharedDrives()\n\n    return () => {\n      isCancelled = true\n    }\n  }, [client, lastUpdate])\n\n  return { isLoading, isLoaded, sharedDrives }\n}\n"
  },
  {
    "path": "src/modules/trash/components/DestroyConfirm.tsx",
    "content": "import React, { useState } from 'react'\n\nimport { splitFilename } from 'cozy-client/dist/models/file'\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ForbiddenIcon from 'cozy-ui/transpiled/react/Icons/Forbidden'\nimport RestoreIcon from 'cozy-ui/transpiled/react/Icons/Restore'\nimport List from 'cozy-ui/transpiled/react/List'\nimport ListItem from 'cozy-ui/transpiled/react/ListItem'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { File } from '@/components/FolderPicker/types'\nimport { getEntriesTypeTranslated } from '@/lib/entries'\n\ninterface DestroyConfirmProps {\n  files: File[]\n  onClose: () => void\n  onConfirm: () => Promise<void>\n}\n\nconst DestroyConfirm: React.FC<DestroyConfirmProps> = ({\n  files,\n  onClose,\n  onConfirm\n}) => {\n  const { t } = useI18n()\n  const [isBusy, setBusy] = useState(false)\n  const { showAlert } = useAlert()\n\n  const entriesType = getEntriesTypeTranslated(t, files)\n\n  const handleDestroy = async (): Promise<void> => {\n    // Prevent double executions\n    if (isBusy) return\n\n    setBusy(true)\n    try {\n      showAlert({\n        message: t('DestroyConfirm.processing', {\n          smart_count: files.length,\n          type: entriesType\n        }),\n        severity: 'info'\n      })\n      onClose()\n      await onConfirm()\n      showAlert({\n        message: t('DestroyConfirm.success', {\n          smart_count: files.length,\n          type: entriesType\n        }),\n        severity: 'success'\n      })\n    } catch {\n      showAlert({\n        message: t('DestroyConfirm.error'),\n        severity: 'error'\n      })\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  const filename = files.length > 0 ? splitFilename(files[0]).filename : ''\n\n  return (\n    <ConfirmDialog\n      open\n      onClose={onClose}\n      title={t('DestroyConfirm.title', {\n        filename,\n        smart_count: files.length,\n        type: entriesType\n      })}\n      content={\n        <List>\n          <ListItem gutters=\"disabled\" size=\"small\" ellipsis={false}>\n            <ListItemIcon>\n              <Icon icon={ForbiddenIcon} />\n            </ListItemIcon>\n            <ListItemText\n              primary={t('DestroyConfirm.forbidden', {\n                smart_count: files.length,\n                type: entriesType\n              })}\n            />\n          </ListItem>\n          <ListItem gutters=\"disabled\" size=\"small\" ellipsis={false}>\n            <ListItemIcon>\n              <Icon icon={RestoreIcon} />\n            </ListItemIcon>\n            <ListItemText\n              primary={t('DestroyConfirm.restore', {\n                smart_count: files.length,\n                type: entriesType\n              })}\n            />\n          </ListItem>\n        </List>\n      }\n      actions={\n        <>\n          <Button\n            variant=\"secondary\"\n            onClick={onClose}\n            label={t('DestroyConfirm.cancel')}\n          />\n          <Button\n            variant=\"primary\"\n            onClick={handleDestroy}\n            label={t('DestroyConfirm.delete')}\n            color=\"error\"\n            busy={isBusy}\n          />\n        </>\n      }\n    />\n  )\n}\n\nexport default DestroyConfirm\n"
  },
  {
    "path": "src/modules/trash/components/EmptyTrashConfirm.tsx",
    "content": "import React, { useCallback, useState } from 'react'\n\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ForbiddenIcon from 'cozy-ui/transpiled/react/Icons/Forbidden'\nimport RestoreIcon from 'cozy-ui/transpiled/react/Icons/Restore'\nimport List from 'cozy-ui/transpiled/react/List'\nimport ListItem from 'cozy-ui/transpiled/react/ListItem'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\ninterface EmptyTrashConfirmProps {\n  onConfirm: () => Promise<void>\n  onClose: () => void\n}\n\nconst EmptyTrashConfirm: React.FC<EmptyTrashConfirmProps> = ({\n  onConfirm,\n  onClose\n}) => {\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n\n  const [isBusy, setBusy] = useState(false)\n\n  const handleConfirm = useCallback(async () => {\n    try {\n      showAlert({\n        message: t('EmptyTrashConfirm.processing'),\n        severity: 'info'\n      })\n      setBusy(true)\n      await onConfirm()\n      showAlert({\n        message: t('EmptyTrashConfirm.success'),\n        severity: 'success'\n      })\n    } catch {\n      showAlert({\n        message: t('EmptyTrashConfirm.error'),\n        severity: 'error'\n      })\n    } finally {\n      setBusy(false)\n      onClose()\n    }\n  }, [onConfirm, onClose, showAlert, t])\n\n  return (\n    <ConfirmDialog\n      open={true}\n      onClose={onClose}\n      title={t('EmptyTrashConfirm.title')}\n      content={\n        <List>\n          <ListItem gutters=\"disabled\" size=\"small\" ellipsis={false}>\n            <ListItemIcon>\n              <Icon icon={ForbiddenIcon} />\n            </ListItemIcon>\n            <ListItemText primary={t('EmptyTrashConfirm.forbidden')} />\n          </ListItem>\n          <ListItem gutters=\"disabled\" size=\"small\" ellipsis={false}>\n            <ListItemIcon>\n              <Icon icon={RestoreIcon} />\n            </ListItemIcon>\n            <ListItemText primary={t('EmptyTrashConfirm.restore')} />\n          </ListItem>\n        </List>\n      }\n      actions={\n        <>\n          <Button\n            variant=\"secondary\"\n            onClick={onClose}\n            label={t('EmptyTrashConfirm.cancel')}\n          />\n          <Button\n            variant=\"primary\"\n            onClick={handleConfirm}\n            label={t('EmptyTrashConfirm.delete')}\n            color=\"error\"\n            busy={isBusy}\n          />\n        </>\n      }\n    />\n  )\n}\n\nexport { EmptyTrashConfirm }\n"
  },
  {
    "path": "src/modules/trash/components/TrashBreadcrumb.tsx",
    "content": "import React, { useMemo, FC, useCallback } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useI18n } from 'twake-i18n'\n\nimport { ROOT_DIR_ID, TRASH_DIR_ID } from '@/constants/config.js'\nimport { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'\nimport { useBreadcrumbPath } from '@/modules/breadcrumb/hooks/useBreadcrumbPath.jsx'\n\ninterface TrashBreadcrumbProps {\n  currentFolderId: string\n}\n\nconst TrashBreadcrumb: FC<TrashBreadcrumbProps> = ({ currentFolderId }) => {\n  const { t } = useI18n()\n  const navigate = useNavigate()\n\n  const rootBreadcrumbPath = useMemo(\n    () => ({\n      id: TRASH_DIR_ID,\n      name: t('breadcrumb.title_trash')\n    }),\n    [t]\n  )\n\n  const path = useBreadcrumbPath({\n    currentFolderId,\n    rootBreadcrumbPath\n  })\n\n  const trashPath = [\n    {\n      id: ROOT_DIR_ID,\n      name: t('breadcrumb.title_drive')\n    },\n    ...path\n  ]\n\n  const handleBreadcrumbClick = useCallback(\n    ({ id }: { id: string }) => {\n      // We can navigate to the root folder inside the breadcrumb\n      if (id === ROOT_DIR_ID) {\n        navigate(`/folder/${ROOT_DIR_ID}`)\n      } else {\n        navigate(`/trash/${id}`)\n      }\n    },\n    [navigate]\n  )\n\n  return (\n    <Breadcrumb\n      path={trashPath}\n      onBreadcrumbClick={handleBreadcrumbClick}\n      opening={false}\n    />\n  )\n}\n\nexport { TrashBreadcrumb }\n"
  },
  {
    "path": "src/modules/trash/components/TrashToolbar.spec.jsx",
    "content": "import { render, fireEvent, act, screen } from '@testing-library/react'\nimport React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { createMockClient } from 'cozy-client'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { TrashToolbar } from './TrashToolbar'\nimport AppLike from 'test/components/AppLike'\n\njest.mock('cozy-ui/transpiled/react/providers/Breakpoints', () => ({\n  ...jest.requireActual('cozy-ui/transpiled/react/providers/Breakpoints'),\n  __esModule: true,\n  default: jest.fn(),\n  useBreakpoints: jest.fn()\n}))\n\njest.mock('react-router-dom', () => ({\n  ...jest.requireActual('react-router-dom'),\n  useNavigate: jest.fn()\n}))\n\ndescribe('TrashToolbar', () => {\n  it('asks for confirmation before emptying the trash', async () => {\n    const mockClient = createMockClient({})\n    const navigateMock = jest.fn()\n\n    useBreakpoints.mockReturnValue({ isMobile: false })\n    useNavigate.mockReturnValue(navigateMock)\n\n    render(\n      <AppLike client={mockClient}>\n        <TrashToolbar disabled={false} emptyTrash={jest.fn()} />\n      </AppLike>\n    )\n\n    const emptyTrashButton = screen.getByText('Empty trash')\n    act(() => {\n      fireEvent.click(emptyTrashButton)\n    })\n\n    expect(navigateMock).toHaveBeenCalledTimes(1)\n    expect(navigateMock).toHaveBeenCalledWith('empty')\n  })\n})\n"
  },
  {
    "path": "src/modules/trash/components/TrashToolbar.tsx",
    "content": "import cx from 'classnames'\nimport React, { FC } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { BarRight } from 'cozy-bar'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { MoreMenu } from '@/components/MoreMenu'\nimport { selectable } from '@/modules/actions/components/selectable'\nimport SearchButton from '@/modules/drive/Toolbar/components/SearchButton'\nimport ViewSwitcher from '@/modules/drive/Toolbar/components/ViewSwitcher'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport { emptyTrash } from '@/modules/trash/components/actions/emptyTrash'\n\nconst TrashToolbar: FC = () => {\n  const { t } = useI18n()\n  const { isMobile } = useBreakpoints()\n  const navigate = useNavigate()\n\n  const { showSelectionBar, isSelectionBarVisible } = useSelectionContext()\n\n  const handleEmptyTrash = (): void => {\n    navigate('empty')\n  }\n\n  const actions = makeActions([selectable, emptyTrash], {\n    t,\n    showSelectionBar,\n    navigate\n  })\n\n  if (isMobile) {\n    return (\n      <BarRight>\n        <SearchButton />\n        <MoreMenu actions={actions} docs={[]} />\n      </BarRight>\n    )\n  }\n\n  return (\n    <div\n      className={cx('u-flex', 'u-flex-items-center', 'u-ml-auto')}\n      role=\"toolbar\"\n    >\n      <ViewSwitcher className=\"u-mr-half\" />\n      <Button\n        variant=\"secondary\"\n        color=\"error\"\n        onClick={handleEmptyTrash}\n        disabled={isSelectionBarVisible}\n        startIcon={<Icon icon={TrashIcon} />}\n        label={t('TrashToolbar.emptyTrash')}\n      />\n    </div>\n  )\n}\n\nexport { TrashToolbar }\n"
  },
  {
    "path": "src/modules/trash/components/actions/destroy.tsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\nimport { navigateToModalWithMultipleFile } from '@/modules/actions/helpers'\nimport type { ActionWithPolicy } from '@/modules/actions/types'\n\ninterface destroyProps {\n  t: (key: string, options?: Record<string, unknown>) => string\n  navigate: (to: string) => void\n  pathname: string\n  search?: string\n}\n\nexport const destroy = ({\n  t,\n  navigate,\n  pathname,\n  search\n}: destroyProps): ActionWithPolicy => {\n  const label = t('SelectionBar.destroy')\n  const icon = TrashIcon\n\n  return {\n    name: 'destroy',\n    label,\n    icon,\n    allowTrashed: true,\n    action: (files): void => {\n      navigateToModalWithMultipleFile({\n        files,\n        pathname,\n        navigate,\n        path: 'destroy',\n        search\n      })\n    },\n    Component: forwardRef(function Destroy(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} color=\"var(--errorColor)\" />\n          </ListItemIcon>\n          <ListItemText\n            primary={label}\n            primaryTypographyProps={{ color: 'error' }}\n          />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n"
  },
  {
    "path": "src/modules/trash/components/actions/emptyTrash.tsx",
    "content": "import React, { forwardRef } from 'react'\n\nimport { Action } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport ActionsMenuItem from 'cozy-ui/transpiled/react/ActionsMenu/ActionsMenuItem'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport TrashIcon from 'cozy-ui/transpiled/react/Icons/Trash'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\n\ninterface emptyTrashProps {\n  t: (key: string, options?: Record<string, unknown>) => string\n  navigate: (to: string) => void\n}\n\nexport const emptyTrash = ({ t, navigate }: emptyTrashProps): Action => {\n  const label = t('TrashToolbar.emptyTrash')\n  const icon = TrashIcon\n\n  return {\n    name: 'emptyTrash',\n    label,\n    icon,\n    action: (): void => {\n      navigate('empty')\n    },\n    Component: forwardRef(function EmptyTrash(props, ref) {\n      return (\n        <ActionsMenuItem {...props} ref={ref}>\n          <ListItemIcon>\n            <Icon icon={icon} color=\"var(--errorColor)\" />\n          </ListItemIcon>\n          <ListItemText\n            primary={label}\n            primaryTypographyProps={{ color: 'error' }}\n          />\n        </ActionsMenuItem>\n      )\n    })\n  }\n}\n"
  },
  {
    "path": "src/modules/upload/Dropzone.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\nimport { useDropzone } from 'react-dropzone'\nimport { useDispatch } from 'react-redux'\n\nimport { useClient } from 'cozy-client'\nimport { useSharingContext } from 'cozy-sharing'\nimport { Content } from 'cozy-ui/transpiled/react/Layout'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/dropzone.styl'\n\nimport RightClickAddMenu from '@/components/RightClick/RightClickAddMenu'\nimport { uploadFiles } from '@/modules/navigation/duck'\nimport DropzoneTeaser from '@/modules/upload/DropzoneTeaser'\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\nconst canDrop = evt => {\n  const items = evt.dataTransfer.items\n  for (let i = 0; i < items.length; i += 1) {\n    if (items[i].kind !== 'file') return false\n  }\n  return true\n}\n\nexport const Dropzone = ({\n  displayedFolder,\n  disabled,\n  refreshFolderContent = null,\n  children\n}) => {\n  const client = useClient()\n  const { t } = useI18n()\n  const { isMobile } = useBreakpoints()\n  const { showAlert } = useAlert()\n  const sharingState = useSharingContext()\n  const dispatch = useDispatch()\n  const { addItems } = useNewItemHighlightContext()\n\n  const fileUploadCallback = refreshFolderContent\n    ? refreshFolderContent\n    : () => null\n\n  const onDrop = async (files, _, evt) => {\n    if (!canDrop(evt)) return\n\n    // react-dropzone v14 (default `getFilesFromEvent: fromEvent` from\n    // file-selector) walks dropped folders and gives us individual File\n    // objects with `.path` set to the relative path inside the dropped\n    // folder. addToUploadQueue uses those paths to flatten into per-file\n    // queue items at enqueue time.\n    dispatch(\n      uploadFiles(\n        files,\n        displayedFolder.id,\n        sharingState,\n        fileUploadCallback,\n        { client, showAlert, t },\n        displayedFolder.driveId,\n        addItems\n      )\n    )\n  }\n\n  const { getRootProps, isDragActive } = useDropzone({\n    onDrop,\n    disabled,\n    noClick: true,\n    noKeyboard: true\n  })\n\n  return (\n    <RightClickAddMenu>\n      <Content\n        className={cx(isMobile ? '' : 'u-pt-1', {\n          [styles['fil-dropzone-active']]: isDragActive\n        })}\n        {...getRootProps()}\n      >\n        {isDragActive && <DropzoneTeaser currentFolder={displayedFolder} />}\n        {children}\n      </Content>\n    </RightClickAddMenu>\n  )\n}\n\nexport default Dropzone\n"
  },
  {
    "path": "src/modules/upload/DropzoneDnD.jsx",
    "content": "import cx from 'classnames'\nimport React from 'react'\nimport { useDrop } from 'react-dnd'\nimport { NativeTypes } from 'react-dnd-html5-backend'\nimport { useDispatch } from 'react-redux'\n\nimport { useClient } from 'cozy-client'\nimport { useSharingContext } from 'cozy-sharing'\nimport { Content } from 'cozy-ui/transpiled/react/Layout'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/styles/dropzone.styl'\n\nimport RightClickAddMenu from '@/components/RightClick/RightClickAddMenu'\nimport { uploadFiles } from '@/modules/navigation/duck'\nimport DropzoneTeaser from '@/modules/upload/DropzoneTeaser'\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\n// DnD helpers for folder upload\nconst canHandleFolders = evt => {\n  if (!evt.dataTransfer) return false\n  const dt = evt.dataTransfer\n  return dt.items && dt.items.length && dt.items[0].webkitGetAsEntry != null\n}\n\nconst canDropHelper = evt => {\n  const items = evt.dataTransfer.items\n  for (let i = 0; i < items.length; i += 1) {\n    if (items[i].kind !== 'file') return false\n  }\n  return true\n}\n\nexport const Dropzone = ({\n  displayedFolder,\n  disabled,\n  refreshFolderContent = null,\n  children\n}) => {\n  const client = useClient()\n  const { t } = useI18n()\n  const { isMobile } = useBreakpoints()\n  const { showAlert } = useAlert()\n  const sharingState = useSharingContext()\n  const dispatch = useDispatch()\n  const { addItems } = useNewItemHighlightContext()\n\n  const fileUploadCallback = refreshFolderContent\n    ? refreshFolderContent\n    : () => null\n\n  const [{ canDrop, isOver }, dropRef] = useDrop(\n    () => ({\n      accept: [NativeTypes.FILE],\n      canDrop: item => !disabled && canDropHelper(item),\n      drop(item) {\n        if (disabled) return\n        const filesToUpload = canHandleFolders(item)\n          ? item.dataTransfer.items\n          : item.files\n        dispatch(\n          uploadFiles(\n            filesToUpload,\n            displayedFolder._id,\n            sharingState,\n            fileUploadCallback,\n            { client, showAlert, t },\n            displayedFolder.driveId,\n            addItems\n          )\n        )\n      },\n      collect: monitor => {\n        return {\n          isOver: monitor.isOver(),\n          canDrop: monitor.canDrop()\n        }\n      }\n    }),\n    [displayedFolder]\n  )\n\n  const isActive = canDrop && isOver\n\n  return (\n    <RightClickAddMenu>\n      <Content\n        ref={dropRef}\n        className={cx(isMobile ? '' : 'u-pt-1', {\n          [styles['fil-dropzone-active']]: isActive\n        })}\n      >\n        {isActive && <DropzoneTeaser currentFolder={displayedFolder} />}\n        {children}\n      </Content>\n    </RightClickAddMenu>\n  )\n}\n\nconst DropzoneWrapper = ({\n  displayedFolder,\n  disabled,\n  refreshFolderContent,\n  children\n}) => {\n  const { isMobile } = useBreakpoints()\n\n  if (disabled) {\n    return <Content className={isMobile ? '' : 'u-pt-1'}>{children}</Content>\n  }\n\n  return (\n    <Dropzone\n      displayedFolder={displayedFolder}\n      disabled={disabled}\n      refreshFolderContent={refreshFolderContent}\n    >\n      {children}\n    </Dropzone>\n  )\n}\n\nexport default DropzoneWrapper\n"
  },
  {
    "path": "src/modules/upload/DropzoneTeaser.jsx",
    "content": "import React from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport UploadIcon from 'cozy-ui/transpiled/react/Icons/Upload'\nimport { translate } from 'twake-i18n'\n\nimport styles from '@/styles/dropzone.styl'\n\nconst DropzoneTeaser = translate()(({ t, currentFolder }) => (\n  <div className={styles['fil-dropzone-teaser']}>\n    <div className={styles['fil-dropzone-teaser-claudy']}>\n      <Icon icon={UploadIcon} size={24} color=\"var(--white)\" />\n    </div>\n    <div className={styles['fil-dropzone-teaser-content']}>\n      <p>{t('Files.dropzone.teaser')}</p>\n      <span className={styles['fil-dropzone-teaser-folder']}>\n        {(currentFolder && currentFolder.name) || 'Drive'}\n      </span>\n    </div>\n  </div>\n))\nexport default DropzoneTeaser\n"
  },
  {
    "path": "src/modules/upload/NewItemHighlightProvider.jsx",
    "content": "import React, { createContext, useCallback, useContext, useState } from 'react'\n\nconst NewItemHighlightContext = createContext()\n\nconst NewItemHighlightProvider = ({ children }) => {\n  const [highlightedItems, setHighlightedItems] = useState([])\n  const [ids, setIds] = useState(new Set())\n\n  const addItems = newItems => {\n    if (!Array.isArray(newItems)) {\n      throw new Error('addItems expects an array')\n    }\n\n    const validItems = newItems.filter(item => item?._id)\n    if (validItems.length === 0) return\n\n    setHighlightedItems(validItems)\n    setIds(new Set(validItems.map(item => item._id)))\n  }\n\n  const clearItems = useCallback(() => {\n    setHighlightedItems([])\n    setIds(new Set())\n  }, [setHighlightedItems, setIds])\n\n  const isNew = item => {\n    return item?._id ? ids.has(item._id) : false\n  }\n\n  return (\n    <NewItemHighlightContext.Provider\n      value={{ highlightedItems, addItems, clearItems, isNew }}\n    >\n      {children}\n    </NewItemHighlightContext.Provider>\n  )\n}\n\nconst useNewItemHighlightContext = () => {\n  const ctx = useContext(NewItemHighlightContext)\n\n  if (!ctx)\n    throw new Error(\n      'useNewItemHighlightContext must be used within NewItemHighlightProvider'\n    )\n\n  return ctx\n}\n\nexport { NewItemHighlightProvider, useNewItemHighlightContext }\n"
  },
  {
    "path": "src/modules/upload/UploadButton.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\nimport { useDispatch } from 'react-redux'\n\nimport { useClient } from 'cozy-client'\nimport withSharingState from 'cozy-sharing/dist/hoc/withSharingState'\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport FileInput from 'cozy-ui/transpiled/react/FileInput'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport UploadIcon from 'cozy-ui/transpiled/react/Icons/Upload'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { uploadFiles } from '@/modules/navigation/duck'\nimport { usePublicContext } from '@/modules/public/PublicProvider'\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\nconst UploadButton = ({\n  label,\n  disabled,\n  className,\n  displayedFolder,\n  sharingState,\n  componentsProps,\n  onUploaded\n}) => {\n  const { showAlert } = useAlert()\n  const { addItems } = useNewItemHighlightContext()\n  const { t } = useI18n()\n  const dispatch = useDispatch()\n  const client = useClient()\n\n  const onUpload = files => {\n    dispatch(\n      uploadFiles(\n        files,\n        displayedFolder.id,\n        sharingState,\n        onUploaded,\n        { client, showAlert, t },\n        displayedFolder.driveId,\n        addItems\n      )\n    )\n  }\n\n  const { isPublic } = usePublicContext()\n\n  return (\n    <FileInput\n      className={className}\n      label={label}\n      disabled={disabled}\n      multiple\n      onChange={files => onUpload(files)}\n      data-testid=\"upload-btn\"\n      value={[]} // always erase the value to be able to re-upload the same file\n    >\n      <Button\n        {...componentsProps?.button}\n        variant={isPublic ? 'secondary' : 'primary'}\n        style={\n          isPublic\n            ? undefined\n            : {\n                color: 'var(--primaryTextColor)',\n                backgroundColor: 'var(--paperBackgroundColor)'\n              }\n        }\n        component=\"span\"\n        startIcon={<Icon icon={UploadIcon} size={12} />}\n        label={label}\n      />\n    </FileInput>\n  )\n}\n\nUploadButton.propTypes = {\n  label: PropTypes.string.isRequired,\n  disabled: PropTypes.bool,\n  className: PropTypes.string,\n  componentsProps: PropTypes.object,\n  onUploaded: PropTypes.func,\n  displayedFolder: PropTypes.object, // io.cozy.files\n  // in case of upload conflicts, shared files are not overridden\n  sharingState: PropTypes.object.isRequired\n}\n\nUploadButton.defaultProps = {\n  disabled: false\n}\n\nexport default withSharingState(UploadButton)\n"
  },
  {
    "path": "src/modules/upload/UploadLimitDialog.jsx",
    "content": "import React from 'react'\n\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport DesktopDownloadIcon from 'cozy-ui/transpiled/react/Icons/DesktopDownload'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport { useI18n } from 'twake-i18n'\n\nimport { getDesktopAppDownloadLink } from '@/components/pushClient'\nimport { usePublicContext } from '@/modules/public/PublicProvider'\n\nconst UploadLimitDialog = ({ onClose, maxFileCount }) => {\n  const { t } = useI18n()\n  const { isPublic } = usePublicContext()\n\n  const handleDownloadDesktop = () => {\n    const link = getDesktopAppDownloadLink({ t })\n    window.open(link, '_blank', 'noopener,noreferrer')\n    onClose()\n  }\n\n  return (\n    <ConfirmDialog\n      open\n      onClose={onClose}\n      title={t('upload.limit.title', { limit: maxFileCount })}\n      content={\n        <Typography>\n          {t(isPublic ? 'upload.limit.content_public' : 'upload.limit.content')}\n        </Typography>\n      }\n      actions={\n        isPublic ? (\n          <Button onClick={onClose} label={t('upload.limit.close')} />\n        ) : (\n          <>\n            <Button\n              variant=\"secondary\"\n              onClick={onClose}\n              label={t('upload.limit.cancel')}\n            />\n            <Button\n              onClick={handleDownloadDesktop}\n              label={t('upload.limit.download_desktop')}\n              startIcon={<Icon icon={DesktopDownloadIcon} />}\n            />\n          </>\n        )\n      }\n    />\n  )\n}\n\nexport default UploadLimitDialog\n"
  },
  {
    "path": "src/modules/upload/UploadQueue.jsx",
    "content": "import cx from 'classnames'\nimport React, { useCallback, useEffect } from 'react'\nimport { useDispatch, useSelector } from 'react-redux'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport CheckCircleIcon from 'cozy-ui/transpiled/react/Icons/CheckCircle'\nimport CrossCircleIcon from 'cozy-ui/transpiled/react/Icons/CrossCircle'\nimport SpinnerIcon from 'cozy-ui/transpiled/react/Icons/Spinner'\nimport WarningIcon from 'cozy-ui/transpiled/react/Icons/Warning'\nimport LinearProgress from 'cozy-ui/transpiled/react/LinearProgress'\nimport List from 'cozy-ui/transpiled/react/List'\nimport ListItem from 'cozy-ui/transpiled/react/ListItem'\nimport ListItemIcon from 'cozy-ui/transpiled/react/ListItemIcon'\nimport ListItemText from 'cozy-ui/transpiled/react/ListItemText'\nimport Paper from 'cozy-ui/transpiled/react/Paper'\nimport Tooltip from 'cozy-ui/transpiled/react/Tooltip'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport Button from 'cozy-ui/transpiled/react/deprecated/Button'\nimport { useI18n } from 'twake-i18n'\n\nimport { getUploadQueue, purgeUploadQueue, status as uploadStatus } from '.'\n\nimport { DEFAULT_UPLOAD_PROGRESS_HIDE_DELAY } from '@/constants/config'\nimport getMimeTypeIcon from '@/lib/getMimeTypeIcon'\n\nconst {\n  LOADING,\n  PENDING,\n  RESOLVING,\n  CANCEL,\n  CREATED,\n  UPDATED,\n  ERROR_STATUSES,\n  DONE_STATUSES\n} = uploadStatus\n\nconst IN_PROGRESS = new Set([PENDING, RESOLVING])\n\n// For the determinate progress bar, weight each row by how far it's\n// progressed: PENDING/RESOLVING contribute 0, LOADING contributes its\n// loaded/total byte fraction, terminal statuses (success/error/cancel)\n// contribute 1. Counts only matter as integers for the close-button\n// gating; the bar uses the fractional total.\nconst summarise = queue => {\n  let done = 0\n  let success = 0\n  let progressTotal = 0\n  for (const item of queue) {\n    if (IN_PROGRESS.has(item.status)) continue\n    if (item.status === LOADING) {\n      progressTotal += item.progress?.total\n        ? item.progress.loaded / item.progress.total\n        : 0\n      continue\n    }\n    done++\n    progressTotal += 1\n    if (item.status === CREATED || item.status === UPDATED) success++\n  }\n  return { done, success, progressTotal }\n}\n\nconst popoverStyle = {\n  position: 'fixed',\n  bottom: '0.5rem',\n  right: '1.5rem',\n  width: '30rem',\n  maxWidth: '90%',\n  height: '13.125rem',\n  zIndex: 'var(--zIndex-popover, 1300)',\n  display: 'flex',\n  flexDirection: 'column',\n  overflow: 'hidden',\n  borderRadius: '0.5rem'\n}\n\nconst headerStyle = {\n  minHeight: '2rem',\n  padding: '0.5rem 1rem',\n  margin: 0,\n  fontWeight: 'bold',\n  backgroundColor: 'var(--defaultBackgroundColor)',\n  display: 'flex',\n  alignItems: 'center',\n  justifyContent: 'space-between'\n}\n\nconst contentStyle = {\n  overflow: 'auto',\n  flex: '1 1 auto',\n  // Without min-height: 0 the flex item refuses to shrink below its\n  // content size, so a long queue pushes header + progress bar\n  // out of the popover's clipped area.\n  minHeight: 0\n}\n\nconst FileUploadProgress = ({ progress }) => {\n  if (!progress?.total) return null\n  return (\n    <LinearProgress\n      variant=\"determinate\"\n      value={(progress.loaded / progress.total) * 100}\n    />\n  )\n}\n\nconst UploadItem = ({ item, t }) => {\n  const { file, status, isDirectory, relativePath } = item\n  const displayName = relativePath || file?.name || ''\n  const isResolving = status === RESOLVING\n  const isLoading = status === LOADING\n  const isError = ERROR_STATUSES.includes(status)\n  const isDone = DONE_STATUSES.includes(status)\n  const isPending = status === PENDING\n\n  let statusIcon = null\n  if (isResolving || (isLoading && !item.progress)) {\n    // Use Icon directly rather than cozy-ui's <Spinner>: Spinner wraps the\n    // SVG in a div whose line-box baseline pushes the glyph ~2px above the\n    // sibling label, which looks misaligned in the row.\n    statusIcon = (\n      <Icon icon={SpinnerIcon} color=\"var(--primaryColor)\" spin size={16} />\n    )\n  } else if (status === CANCEL) {\n    statusIcon = <Icon icon={CrossCircleIcon} color=\"var(--errorColor)\" />\n  } else if (isError) {\n    statusIcon = <Icon icon={WarningIcon} color=\"var(--errorColor)\" />\n  } else if (isDone) {\n    statusIcon = <Icon icon={CheckCircleIcon} color=\"var(--successColor)\" />\n  }\n\n  let label = null\n  if (isResolving) label = t('UploadQueue.item.preparing')\n  else if (isPending) label = t('UploadQueue.item.pending')\n\n  return (\n    <ListItem\n      divider\n      data-testid=\"upload-queue-item\"\n      style={\n        isError ? { backgroundColor: 'var(--errorBackground)' } : undefined\n      }\n    >\n      <ListItemIcon className=\"u-ta-center\">\n        <Icon\n          icon={getMimeTypeIcon(isDirectory, displayName, file?.type)}\n          size={32}\n          className=\"u-mr-1\"\n        />\n      </ListItemIcon>\n      <ListItemText\n        disableTypography\n        primary={\n          // Tooltip only when the row carries a relative path — for\n          // loose top-level files `displayName` is just the bare\n          // filename and the tooltip would duplicate the visible label.\n          relativePath ? (\n            <Tooltip title={displayName} placement=\"top\">\n              <Typography\n                variant=\"body1\"\n                className=\"u-ellipsis\"\n                data-testid=\"upload-queue-item-name\"\n              >\n                {displayName}\n              </Typography>\n            </Tooltip>\n          ) : (\n            <Typography\n              variant=\"body1\"\n              className=\"u-ellipsis\"\n              data-testid=\"upload-queue-item-name\"\n            >\n              {displayName}\n            </Typography>\n          )\n        }\n        secondary={\n          isLoading && item.progress ? (\n            <FileUploadProgress progress={item.progress} />\n          ) : null\n        }\n      />\n      {statusIcon && <ListItemIcon>{statusIcon}</ListItemIcon>}\n      {label && (\n        <Typography\n          variant=\"subtitle1\"\n          color=\"primary\"\n          className=\"u-ml-half u-flex-shrink-0\"\n        >\n          {label}\n        </Typography>\n      )}\n    </ListItem>\n  )\n}\n\nconst UploadQueue = () => {\n  const { t } = useI18n()\n  const dispatch = useDispatch()\n  const queue = useSelector(getUploadQueue)\n  const {\n    done: doneCount,\n    success: successCount,\n    progressTotal\n  } = summarise(queue || [])\n  const purgeQueue = useCallback(() => dispatch(purgeUploadQueue()), [dispatch])\n  const queueLength = queue?.length ?? 0\n  // Everything in the queue has reached a terminal state (success or\n  // error). Drives the \"done\" header copy + the manual close button.\n  const allProcessed = queueLength > 0 && doneCount === queueLength\n  // Stricter: every item succeeded. Drives auto-purge so failures stay\n  // visible until the user dismisses them — auto-closing on partial\n  // failures would hide the failed rows behind the toast alert.\n  const allSucceeded = queueLength > 0 && successCount === queueLength\n  const isResolving = (queue || []).some(item => item.status === RESOLVING)\n\n  useEffect(() => {\n    if (allSucceeded) {\n      const timer = setTimeout(purgeQueue, DEFAULT_UPLOAD_PROGRESS_HIDE_DELAY)\n      return () => clearTimeout(timer)\n    }\n  }, [allSucceeded, purgeQueue])\n\n  if (queueLength === 0) return null\n\n  let headerText\n  if (isResolving) {\n    // Count the whole drop, not just the resolving placeholders, so a\n    // mixed drop (loose file + folder) reads as the user dropped it.\n    headerText = t('UploadQueue.header_preparing', {\n      smart_count: queueLength\n    })\n  } else if (allProcessed) {\n    headerText = t('UploadQueue.header_done', {\n      done: successCount,\n      total: queueLength\n    })\n  } else {\n    headerText = t('UploadQueue.header', { smart_count: queueLength })\n  }\n\n  return (\n    <Paper\n      elevation={6}\n      style={popoverStyle}\n      data-testid=\"upload-queue\"\n      className={cx({ 'upload-queue--resolving': isResolving })}\n    >\n      <h4 style={headerStyle}>\n        <Typography variant=\"h6\">{headerText}</Typography>\n        {allProcessed && !isResolving && (\n          <Button\n            subtle\n            className=\"u-mv-0\"\n            label={t('UploadQueue.close')}\n            onClick={purgeQueue}\n          />\n        )}\n      </h4>\n      <LinearProgress\n        variant={isResolving ? 'indeterminate' : 'determinate'}\n        value={isResolving ? undefined : (progressTotal / queueLength) * 100}\n        // flexShrink: 0 keeps the bar at its 4px intrinsic height when\n        // the queue grows long. Without it, the flex algorithm splits\n        // negative free space proportionally to flex-basis and shrinks\n        // the bar to ~0px (the list wrapper's basis dwarfs the bar's).\n        style={{ flexShrink: 0 }}\n      />\n      <div style={contentStyle}>\n        <List className=\"u-pv-0\">\n          {queue.map(item => (\n            <UploadItem key={item.fileId} item={item} t={t} />\n          ))}\n        </List>\n      </div>\n    </Paper>\n  )\n}\n\nexport default UploadQueue\n"
  },
  {
    "path": "src/modules/upload/index.js",
    "content": "import { combineReducers } from 'redux'\n\nimport { getFullpath } from 'cozy-client/dist/models/file'\n\nimport { MAX_PAYLOAD_SIZE } from '@/constants/config'\nimport { DOCTYPE_FILES } from '@/lib/doctypes'\nimport logger from '@/lib/logger'\nimport { CozyFile } from '@/models'\n\nconst SLUG = 'upload'\n\nexport const ADD_TO_UPLOAD_QUEUE = 'ADD_TO_UPLOAD_QUEUE'\nconst UPLOAD_FILE = 'UPLOAD_FILE'\nconst UPLOAD_PROGRESS = 'UPLOAD_PROGRESS'\nexport const RECEIVE_UPLOAD_SUCCESS = 'RECEIVE_UPLOAD_SUCCESS'\nexport const RECEIVE_UPLOAD_ERROR = 'RECEIVE_UPLOAD_ERROR'\nconst PURGE_UPLOAD_QUEUE = 'PURGE_UPLOAD_QUEUE'\nconst RESOLVE_FOLDER_ITEMS = 'RESOLVE_FOLDER_ITEMS'\n\nconst CANCEL = 'cancel'\nconst PENDING = 'pending'\nconst LOADING = 'loading'\nconst CREATED = 'created'\nconst UPDATED = 'updated'\nconst FAILED = 'failed'\nconst CONFLICT = 'conflict'\nconst QUOTA = 'quota'\nconst NETWORK = 'network'\nconst UNREADABLE = 'unreadable'\n// Placeholder status for a top-level folder drop while its tree is\n// being walked and folders are being created server-side. Replaced by\n// real PENDING items once flattenEntries completes.\nconst RESOLVING = 'resolving'\n// Mirrors cozy-stack's ErrMaxFileSize message so client-side pre-flight\n// rejections look identical to server-side ones and funnel through the\n// same classifier branch.\nconst ERR_MAX_FILE_SIZE =\n  'The file is too big and exceeds the filesystem maximum file size'\n\nconst DONE_STATUSES = [CREATED, UPDATED]\nconst ERROR_STATUSES = [CONFLICT, NETWORK, QUOTA, FAILED, UNREADABLE]\nconst IN_PROGRESS_STATUSES = [PENDING, RESOLVING]\n\nexport const status = {\n  CANCEL,\n  PENDING,\n  LOADING,\n  CREATED,\n  UPDATED,\n  FAILED,\n  CONFLICT,\n  QUOTA,\n  NETWORK,\n  UNREADABLE,\n  RESOLVING,\n  DONE_STATUSES,\n  ERROR_STATUSES,\n  ERR_MAX_FILE_SIZE\n}\n\nconst CONFLICT_ERROR = 409\nconst PAYLOAD_TOO_LARGE = 413\n\n// `status` is preserved when set on the input (folder placeholder rows\n// arrive with status: RESOLVING); real file items have no status and\n// default to PENDING so processNextFile picks them up.\nconst itemInitialState = item => ({\n  ...item,\n  status: item.status || PENDING,\n  progress: null\n})\n\nconst getStatus = (state, action) => {\n  switch (action.type) {\n    case UPLOAD_FILE:\n      return LOADING\n    case RECEIVE_UPLOAD_SUCCESS:\n      return action.isUpdate ? UPDATED : CREATED\n    case RECEIVE_UPLOAD_ERROR:\n      return action.status\n    default:\n      return state\n  }\n}\n\nconst getSpeed = (state, action) => {\n  const lastLoaded = state.loaded\n  const lastUpdated = state.lastUpdated\n  const now = action.date\n  const nowLoaded = action.loaded\n  return ((nowLoaded - lastLoaded) / (now - lastUpdated)) * 1000\n}\n\nlet remainingTimes = []\nlet averageRemainingTime = undefined\nlet timeout = undefined\n\nconst getProgress = (state, action) => {\n  if (action.type == RECEIVE_UPLOAD_SUCCESS) {\n    return null\n  } else if (action.type === UPLOAD_PROGRESS) {\n    const speed = state ? getSpeed(state, action) : null\n    const loaded = action.loaded\n    const total = action.total\n    const instantRemainingTime =\n      speed && total && loaded ? (total - loaded) / speed : null\n\n    if (!averageRemainingTime) {\n      averageRemainingTime = instantRemainingTime\n    }\n\n    if (instantRemainingTime) {\n      remainingTimes.push(instantRemainingTime)\n    }\n\n    if (!timeout) {\n      timeout = setTimeout(() => {\n        averageRemainingTime =\n          remainingTimes.reduce((a, b) => a + b, 0) / remainingTimes.length\n\n        clearTimeout(timeout)\n        timeout = undefined\n        remainingTimes = []\n      }, 3000)\n    }\n\n    return {\n      loaded,\n      total,\n      lastUpdated: action.date,\n      speed,\n      remainingTime: averageRemainingTime\n    }\n  } else if (action.type === RECEIVE_UPLOAD_ERROR) {\n    return null\n  } else {\n    return state\n  }\n}\n\nconst item = (state, action = { isUpdate: false }) => {\n  const resolvedUploadedItem =\n    action.uploadedItem !== undefined\n      ? action.uploadedItem\n      : state?.uploadedItem\n\n  return {\n    ...state,\n    status: getStatus(state.status, action),\n    progress: getProgress(state.progress, action),\n    ...(resolvedUploadedItem !== undefined\n      ? { uploadedItem: resolvedUploadedItem }\n      : {})\n  }\n}\n\nexport const queue = (state = [], action) => {\n  switch (action.type) {\n    case ADD_TO_UPLOAD_QUEUE:\n      return [\n        ...state.filter(i => i.status !== CREATED),\n        ...action.files.map(f => itemInitialState(f))\n      ]\n    case RESOLVE_FOLDER_ITEMS: {\n      const placeholderIds = new Set(action.placeholderIds)\n      const filtered = state.filter(i => !placeholderIds.has(i.fileId))\n      // If purgeUploadQueue ran while flattenEntries was in flight, the\n      // placeholders are gone — drop the resolved files too so a cancelled\n      // drop doesn't silently re-fill the queue and resume uploading.\n      if (filtered.length === state.length) return state\n      return [...filtered, ...action.files.map(f => itemInitialState(f))]\n    }\n    case PURGE_UPLOAD_QUEUE:\n      return []\n    case UPLOAD_FILE:\n    case RECEIVE_UPLOAD_SUCCESS:\n    case RECEIVE_UPLOAD_ERROR:\n    case UPLOAD_PROGRESS: {\n      // No matching row (e.g. the queue was purged before a stale\n      // dispatch landed): return the same reference so connected\n      // consumers don't re-render needlessly.\n      if (!state.some(i => i.fileId === action.fileId)) return state\n      return state.map(i => (i.fileId !== action.fileId ? i : item(i, action)))\n    }\n    default:\n      return state\n  }\n}\n\nexport default combineReducers({\n  queue\n})\n\nexport const uploadProgress = (fileId, event, date) => ({\n  type: UPLOAD_PROGRESS,\n  fileId,\n  loaded: event.loaded,\n  total: event.total,\n  date: date || Date.now()\n})\n\n/**\n * Upload a single pending queue item: resolve its target directory\n * (server-side folder id if the item came from a flattened folder drop,\n * otherwise the caller-supplied `dirID`) and dispatch the upload\n * lifecycle actions.\n *\n * Kept separate from {@link processNextFile} so the outer thunk only\n * has the queue-draining loop and error funnel.\n *\n * @param {{file: File, fileId: string, folderId?: string}} pendingItem\n * @param {object} client - cozy-client instance\n * @param {string} dirID - Fallback directory when the item has no folderId\n * @param {string} [driveId]\n * @param {{dispatch: Function, safeCallback: Function}} io\n */\nconst uploadPendingItem = async (\n  pendingItem,\n  client,\n  dirID,\n  driveId,\n  { dispatch, safeCallback }\n) => {\n  const { file, fileId, folderId } = pendingItem\n  const targetDirId = folderId ?? dirID\n  try {\n    dispatch({ type: UPLOAD_FILE, fileId, file })\n    const onUploadProgress = event => dispatch(uploadProgress(fileId, event))\n    const { data: uploadedFile, isUpdate } = await uploadOrOverwriteFile(\n      client,\n      file,\n      targetDirId,\n      { onUploadProgress },\n      driveId\n    )\n    safeCallback(uploadedFile)\n    dispatch({\n      type: RECEIVE_UPLOAD_SUCCESS,\n      fileId,\n      file,\n      isUpdate,\n      uploadedItem: uploadedFile\n    })\n  } catch (error) {\n    logger.error(\n      `Upload module catches an error when executing processNextFile(): ${error}`\n    )\n    dispatch({\n      type: RECEIVE_UPLOAD_ERROR,\n      fileId,\n      file,\n      status: classifyUploadError(error)\n    })\n  }\n}\n\nexport const processNextFile =\n  (\n    fileUploadedCallback,\n    queueCompletedCallback,\n    dirID,\n    sharingState,\n    { client },\n    driveId,\n    addItems\n  ) =>\n  async (dispatch, getState) => {\n    const safeCallback =\n      typeof fileUploadedCallback === 'function'\n        ? fileUploadedCallback\n        : () => {}\n    if (!client) {\n      throw new Error(\n        'Upload module needs a cozy-client instance to work. This instance should be made available by using the extraArgument function of redux-thunk'\n      )\n    }\n    const pendingItem = getUploadQueue(getState()).find(\n      i => i.status === PENDING\n    )\n    if (!pendingItem) {\n      return dispatch(onQueueEmpty(queueCompletedCallback))\n    }\n    await uploadPendingItem(pendingItem, client, dirID, driveId, {\n      dispatch,\n      safeCallback\n    })\n    dispatch(\n      processNextFile(\n        fileUploadedCallback,\n        queueCompletedCallback,\n        dirID,\n        sharingState,\n        { client },\n        driveId,\n        addItems\n      )\n    )\n  }\n\nconst getFileFromEntry = entry =>\n  new Promise((resolve, reject) => entry.file(resolve, reject))\n\nconst readNextBatch = dirReader =>\n  new Promise((resolve, reject) => dirReader.readEntries(resolve, reject))\n\n/**\n * Read all entries from a directory upfront so file entries can be\n * converted to File objects before uploads start. This prevents\n * NotFoundError when the browser discards stale FileSystemEntry\n * references during long sequential uploads of large directories.\n */\nconst readAllEntries = async dirReader => {\n  const entries = []\n  let batch\n  while ((batch = await readNextBatch(dirReader)).length > 0) {\n    entries.push(...batch)\n  }\n  return entries\n}\n\nconst resolveFullpath = (client, dirID, name, driveId) =>\n  driveId\n    ? getFullpath(client, dirID, name, driveId)\n    : CozyFile.getFullpath(dirID, name)\n\nconst getExistingDirectory = async (client, dirID, name, driveId) => {\n  const path = await resolveFullpath(client, dirID, name, driveId)\n  const statResp = await client\n    .collection(DOCTYPE_FILES, { driveId })\n    .statByPath(path)\n  if (statResp.data.type !== 'directory') {\n    throw new Error(`\"${path}\" already exists and is not a directory`)\n  }\n  return statResp.data\n}\n\n/**\n * Map an upload failure to one of the queue item error statuses.\n *\n * Precedence matters: the Chrome pre-flight in `uploadFile` throws an\n * `Error` whose message is a JSON blob carrying `status: 413` (no such\n * property on the error itself), so the `ERR_MAX_FILE_SIZE` message\n * match has to run before the `PAYLOAD_TOO_LARGE` status check. The\n * plain disk-usage guard in the same function throws with\n * `error.status = PAYLOAD_TOO_LARGE`, which the later branch catches.\n *\n * @param {Error} error\n * @returns {string} One of the values exported in `status`.\n */\nconst classifyUploadError = error => {\n  if (error.name === 'NotFoundError') return UNREADABLE\n  if (error.message?.includes(ERR_MAX_FILE_SIZE)) return ERR_MAX_FILE_SIZE\n  if (error.status === CONFLICT_ERROR) return CONFLICT\n  if (error.status === PAYLOAD_TOO_LARGE) return QUOTA\n  if (/Failed to fetch$/.test(error.toString())) return NETWORK\n  return FAILED\n}\n\n/**\n * Upload a file, or silently overwrite the existing version on 409.\n *\n * @param {object} client - cozy-client instance\n * @param {File} file\n * @param {string} dirID\n * @param {{onUploadProgress?: Function}} options\n * @param {string} [driveId]\n * @returns {Promise<{data: object, isUpdate: boolean}>} The created\n *   (`isUpdate: false`) or overwritten (`isUpdate: true`) file document.\n */\nconst uploadOrOverwriteFile = async (client, file, dirID, options, driveId) => {\n  try {\n    const data = await uploadFile(client, file, dirID, options, driveId)\n    return { data, isUpdate: false }\n  } catch (err) {\n    if (err.status !== CONFLICT_ERROR) throw err\n    const path = await resolveFullpath(client, dirID, file.name, driveId)\n    const data = await overwriteFile(client, file, path, options, driveId)\n    return { data, isUpdate: true }\n  }\n}\n\n/**\n * Create a folder, or return the existing one on 409.\n *\n * Used by the flatten helpers when walking a dropped folder tree: we\n * reuse an existing same-name directory instead of failing.\n * `getExistingDirectory` throws a plain `Error` (no `status`) if a\n * non-directory sits at that name, which bubbles up as a normal upload\n * failure rather than being silently overwritten.\n *\n * @param {object} client - cozy-client instance\n * @param {string} name\n * @param {string} dirID - Parent directory id\n * @param {string} [driveId]\n * @returns {Promise<object>} The `io.cozy.files` document of the created\n *   or existing directory.\n */\nconst createFolderOrGetExisting = async (client, name, dirID, driveId) => {\n  try {\n    return await createFolder(client, name, dirID, driveId)\n  } catch (err) {\n    if (err.status !== CONFLICT_ERROR) throw err\n    return getExistingDirectory(client, dirID, name, driveId)\n  }\n}\n\nconst createFolder = async (client, name, dirID, driveId) => {\n  const resp = await client\n    .collection(DOCTYPE_FILES, { driveId })\n    .createDirectory({ name, dirId: dirID })\n  return resp.data\n}\n\nconst uploadFile = async (client, file, dirID, options = {}, driveId) => {\n  /** We have a bug with Chrome returning SPDY_ERROR_PROTOCOL.\n   * This is certainly caused by the couple HTTP2 / HAProxy / CozyStack\n   * when something cut the HTTP connexion before the Stack\n   *\n   * We can not intercept this error since Chrome only returns\n   * `Failed to fetch` as if we were offline. The only workaround for\n   * now, is to check if we'll have enough size on the Cozy before\n   * trying to upload the file to detect if we'll go out of quota\n   * before connexion being cut by something.\n   *\n   * We don't need to do that work on other browser (window.chrome\n   * should be available on new Edge, Chrome, Chromium, Brave, Opera...)\n   */\n  if (window.chrome) {\n    const fileSize = parseInt(file.size, 10)\n\n    if (fileSize > MAX_PAYLOAD_SIZE) {\n      // Match cozy-stack error format\n      throw new Error(\n        JSON.stringify({\n          status: PAYLOAD_TOO_LARGE,\n          title: 'Request Entity Too Large',\n          detail: ERR_MAX_FILE_SIZE\n        })\n      )\n    }\n\n    const { data: diskUsage } = await client\n      .getStackClient()\n      .fetchJSON('GET', '/settings/disk-usage')\n    if (diskUsage.attributes.quota) {\n      const usedSpace = parseInt(diskUsage.attributes.used, 10)\n      const totalQuota = parseInt(diskUsage.attributes.quota, 10)\n      const availableSpace = totalQuota - usedSpace\n\n      if (fileSize > availableSpace) {\n        const error = new Error('Insufficient Disk Space')\n        error.status = PAYLOAD_TOO_LARGE\n        throw error\n      }\n    }\n  }\n\n  const { onUploadProgress } = options\n\n  const resp = await client\n    .collection(DOCTYPE_FILES, { driveId })\n    .createFile(file, { dirId: dirID, onUploadProgress })\n\n  return resp.data\n}\n\n/**\n * @param {object} client - A CozyClient instance\n * @param {File} file - The javascript File object to upload\n * @param {string} path - The target file's path in the cozy\n * @param {{onUploadProgress: Function}} [options]\n * @param {string} [driveId] - Shared drive id\n * @returns {Promise<object>} The updated io.cozy.files document\n */\nexport const overwriteFile = async (\n  client,\n  file,\n  path,\n  options = {},\n  driveId = null\n) => {\n  const statResp = await client\n    .collection(DOCTYPE_FILES, { driveId })\n    .statByPath(path)\n  const { id: fileId } = statResp.data\n  // updateFile destructures known param keys (fileId, name, …) and\n  // treats the rest as upload options (onUploadProgress, etc.) — so\n  // they must sit at the top level of the second argument, not nested\n  // under an `options` key.\n  const resp = await client\n    .collection(DOCTYPE_FILES, { driveId })\n    .updateFile(file, { fileId, ...options })\n\n  return resp.data\n}\n\n/**\n * Build a flat queue item.\n *\n * `fileId` is the identity the reducer uses for progress/success/error\n * updates, so it must be unique per item. The relative path (or bare\n * filename for loose files) makes two `img.jpg`s in different folders\n * distinct, but the same drop can be made twice in a row — the nonce\n * scopes the id to one drop so two `photos/img.jpg` items from two\n * drops don't collide and have a single dispatch flip both rows.\n *\n * @param {File} file\n * @param {string} folderId - Server id of the folder the file goes into\n * @param {string|null} relativePath - `\"photos/2024/img.jpg\"` when the\n *   file came from a dropped folder, `null` for loose files\n * @param {string} nonce - Per-drop nonce; `''` keeps the legacy id\n *   shape for callers that don't care about cross-drop uniqueness.\n * @returns {{fileId: string, file: File, relativePath: string|null, folderId: string}}\n */\nconst makeFlatItem = (file, folderId, relativePath = null, nonce = '') => {\n  const base = relativePath ?? file.name\n  return {\n    fileId: nonce ? `${nonce}_${base}` : base,\n    file,\n    relativePath,\n    folderId\n  }\n}\n\n/**\n * Build a queue item representing an entry we couldn't read locally.\n *\n * The status is preset (via {@link classifyUploadError}) so the reducer\n * keeps it instead of promoting the row to PENDING. A `NotFoundError`\n * lands on UNREADABLE (firing the unreadable-files alert downstream);\n * permission / generic I/O errors land on FAILED rather than being\n * silently mislabelled. The synthetic `file` shim carries the entry's\n * display name so the upload tray can render the row even though we\n * never obtained a real `File` object. `isDirectory` is propagated so\n * the queue UI renders the folder glyph for unreadable directories,\n * making them visually distinct from unreadable files.\n *\n * @param {string} name - Local entry name (file or folder)\n * @param {string|null} relativePath - Path of the failed entry relative\n *   to the drop root (e.g. `\"a/b/c/d/e\"`), or `null` for top-level\n *   loose entries\n * @param {string} nonce - Per-drop nonce, same shape as in {@link makeFlatItem}\n * @param {Error} error - The rejection from `readEntries` or `entry.file()`\n * @param {boolean} [isDirectory=false] - `true` when the failed entry\n *   was a directory (its `readEntries` rejected); `false` for a file\n *   whose `entry.file()` rejected\n * @returns {{fileId: string, file: {name: string, type: string},\n *   relativePath: string|null, folderId: null, status: string,\n *   isDirectory: boolean}}\n */\nconst makeFailedItem = (\n  name,\n  relativePath,\n  nonce,\n  error,\n  isDirectory = false\n) => {\n  const base = relativePath ?? name\n  return {\n    fileId: nonce ? `${nonce}_${base}` : base,\n    file: { name, type: '' },\n    relativePath,\n    folderId: null,\n    isDirectory,\n    status: classifyUploadError(error)\n  }\n}\n\n/**\n * @typedef {object} WalkedNode\n * @property {string} name - Local entry name\n * @property {true} [readFailed] - Set when `readEntries` rejected; the\n *   node has no children and `error` carries the rejection\n * @property {Error} [error] - Present iff `readFailed` is true\n * @property {File[]} [files] - Successfully extracted `File` objects\n * @property {Array<{name: string, error: Error}>} [failedFiles] -\n *   Child file entries whose `entry.file()` rejected\n * @property {WalkedNode[]} [subdirs] - Recursively-walked subdirectories\n */\n\n/**\n * Walk a `FileSystemDirectoryEntry` locally without touching the\n * server. Read failures (long-path `NotFoundError` on Windows, vanished\n * entries) are captured per-node instead of thrown, so the materialize\n * step can finish creating the surrounding tree and surface failed\n * reads as queue rows.\n *\n * Files within a directory are extracted via `Promise.all` (parallel),\n * matching the original code's concurrency. Subdirs are recursed into\n * sequentially to keep the parallelism bounded on wide trees.\n *\n * @param {FileSystemDirectoryEntry} dirEntry\n * @returns {Promise<WalkedNode>}\n */\nconst walkDirectoryEntry = async dirEntry => {\n  let childEntries\n  try {\n    childEntries = await readAllEntries(dirEntry.createReader())\n  } catch (error) {\n    return { name: dirEntry.name, readFailed: true, error }\n  }\n  const fileEntries = childEntries.filter(c => c.isFile)\n  const subdirEntries = childEntries.filter(c => c.isDirectory)\n  const files = []\n  const failedFiles = []\n  const filePromises = fileEntries.map(c =>\n    getFileFromEntry(c).then(\n      f => files.push(f),\n      error => failedFiles.push({ name: c.name, error })\n    )\n  )\n  await Promise.all(filePromises)\n  const subdirs = []\n  for (const sub of subdirEntries) {\n    subdirs.push(await walkDirectoryEntry(sub))\n  }\n  return { name: dirEntry.name, files, failedFiles, subdirs }\n}\n\n/**\n * @typedef {{fileId: string, file: File, relativePath: string|null, folderId: string}} ReadableFlatItem\n * @typedef {{fileId: string, file: {name: string, type: string},\n *   relativePath: string|null, folderId: null, status: string,\n *   isDirectory: boolean}} FailedFlatItem\n */\n\n/**\n * Materialize a walked tree: create the server folder for every node\n * we visited (including empty ones and ones whose `readEntries` failed\n * locally), emit a flat queue item per readable file, and emit one\n * error row per failed read (folder or file).\n *\n * Folders are created unconditionally so the resulting tree in Drive\n * matches the shape that was dropped. Empty folders survive the round\n * trip; folders whose contents couldn't be read appear empty AND carry\n * a queue row pointing at the missing subtree so the user can drop the\n * files back in by hand.\n *\n * @param {WalkedNode} node - A node returned by {@link walkDirectoryEntry}\n * @param {string} parentDirId - Server id of the enclosing directory\n *   (i.e. the dir into which this node will be created)\n * @param {string} pathPrefix - Relative path accumulated so far,\n *   without a trailing slash; `''` at the drop root\n * @param {{client: object, driveId?: string, nonce: string}} ctx -\n *   Drop-invariant deps grouped to keep the recursion-changing args\n *   (`node`, `parentDirId`, `pathPrefix`) positional and short\n * @returns {Promise<Array<ReadableFlatItem|FailedFlatItem>>}\n */\nconst materializeNode = async (node, parentDirId, pathPrefix, ctx) => {\n  const newPrefix = pathPrefix ? `${pathPrefix}/${node.name}` : node.name\n  const newDir = await createFolderOrGetExisting(\n    ctx.client,\n    node.name,\n    parentDirId,\n    ctx.driveId\n  )\n  if (node.readFailed) {\n    return [makeFailedItem(node.name, newPrefix, ctx.nonce, node.error, true)]\n  }\n\n  const items = node.failedFiles.map(ff =>\n    makeFailedItem(\n      ff.name,\n      `${newPrefix}/${ff.name}`,\n      ctx.nonce,\n      ff.error,\n      false\n    )\n  )\n  for (const file of node.files) {\n    items.push(\n      makeFlatItem(file, newDir.id, `${newPrefix}/${file.name}`, ctx.nonce)\n    )\n  }\n  for (const sub of node.subdirs) {\n    const subItems = await materializeNode(sub, newDir.id, newPrefix, ctx)\n    items.push(...subItems)\n  }\n  return items\n}\n\n/**\n * Build a memoised `ensureFolder(path)` function that creates (or\n * reuses) nested folders under `rootDirId`, one server call per unique\n * path segment.\n *\n * @param {string} rootDirId\n * @param {object} client - cozy-client instance\n * @param {string} [driveId]\n * @returns {(folderPath: string) => Promise<string>} Resolves to the\n *   server id of the folder at `folderPath` (relative to root).\n */\nconst makeFolderResolver = (rootDirId, client, driveId) => {\n  const cache = new Map([['', rootDirId]])\n  const ensure = async folderPath => {\n    if (cache.has(folderPath)) return cache.get(folderPath)\n    const lastSlash = folderPath.lastIndexOf('/')\n    const parentPath = lastSlash > 0 ? folderPath.slice(0, lastSlash) : ''\n    const name = lastSlash > 0 ? folderPath.slice(lastSlash + 1) : folderPath\n    const parentId = await ensure(parentPath)\n    const folder = await createFolderOrGetExisting(\n      client,\n      name,\n      parentId,\n      driveId\n    )\n    cache.set(folderPath, folder.id)\n    return folder.id\n  }\n  return ensure\n}\n\n/**\n * Flatten a mixed list of dropped entries into per-file queue items.\n *\n * Three entry shapes are handled in a single pass:\n * - `{isDirectory: true, entry}` — a `FileSystemEntry` from drag-and-drop;\n *   walked locally first via {@link walkDirectoryEntry}, then realized\n *   server-side via {@link materializeNode}. Read failures along the\n *   walk become per-entry UNREADABLE rows instead of throwing, so a\n *   long-path NotFoundError on Windows doesn't leave orphan folders\n *   server-side.\n * - `{file}` whose `file.path` contains a `/` — a react-dropzone /\n *   file-selector File with a relative path; folders are created on the\n *   fly via the path-based resolver.\n * - `{file}` with no folder structure — placed directly under `rootDirId`.\n *\n * Intermediate folders are created (or reused on 409) server-side\n * before any file upload starts, so `processNextFile` only ever handles\n * single-file items and there is exactly one place in the module that\n * resolves folder conflicts.\n *\n * @param {Array<{file: File|null, isDirectory?: boolean, entry?: FileSystemEntry|null}>} entries\n * @param {string} rootDirId - Directory id where the drop happened\n * @param {object} client - cozy-client instance\n * @param {string} [driveId]\n * @returns {Promise<Array<{fileId: string, file: File, relativePath: string|null, folderId: string}>>}\n */\nexport const flattenEntries = async (\n  entries,\n  rootDirId,\n  client,\n  driveId,\n  nonce = ''\n) => {\n  const ensureFolder = makeFolderResolver(rootDirId, client, driveId)\n  const result = []\n  for (const entry of entries) {\n    if (entry.isDirectory && entry.entry) {\n      const tree = await walkDirectoryEntry(entry.entry)\n      const subItems = await materializeNode(tree, rootDirId, '', {\n        client,\n        driveId,\n        nonce\n      })\n      result.push(...subItems)\n      continue\n    }\n    const file = entry.file\n    if (!file) continue\n    const raw = file.path || ''\n    const cleanPath = raw.startsWith('/') ? raw.slice(1) : raw\n    if (!cleanPath.includes('/')) {\n      result.push(makeFlatItem(file, rootDirId, null, nonce))\n    } else {\n      const folderPath = cleanPath.slice(0, cleanPath.lastIndexOf('/'))\n      const folderId = await ensureFolder(folderPath)\n      result.push(makeFlatItem(file, folderId, cleanPath, nonce))\n    }\n  }\n  return result\n}\n\nexport const removeFileToUploadQueue = file => async dispatch => {\n  dispatch({\n    type: RECEIVE_UPLOAD_SUCCESS,\n    fileId: file.name,\n    file,\n    isUpdate: true\n  })\n}\n\n/**\n * An entry is \"deferred\" if it needs flattening — either a directory\n * drag-drop or a react-dropzone file with a folder structure encoded\n * in its `path`. Loose top-level files are NOT deferred and can be\n * queued immediately as PENDING items.\n */\nconst isDeferredEntry = entry => {\n  if (entry.isDirectory && entry.entry) return true\n  const path = entry.file?.path\n  if (!path) return false\n  const cleanPath = path.startsWith('/') ? path.slice(1) : path\n  return cleanPath.includes('/')\n}\n\n/**\n * Identify the top-level folder names a drop will create. Used to seed\n * \"resolving\" placeholder rows so the upload tray appears immediately,\n * before the (potentially slow) tree walk creates folders server-side.\n *\n * @param {Array<{file: File|null, isDirectory?: boolean, entry?: object|null}>} entries\n * @returns {string[]} Unique top-level folder names\n */\nconst collectFolderRoots = entries => {\n  const roots = new Set()\n  for (const e of entries) {\n    if (e.isDirectory && e.entry) {\n      roots.add(e.entry.name)\n      continue\n    }\n    const path = e.file?.path\n    if (!path) continue\n    const cleanPath = path.startsWith('/') ? path.slice(1) : path\n    if (cleanPath.includes('/')) {\n      roots.add(cleanPath.split('/')[0])\n    }\n  }\n  return [...roots]\n}\n\n// Placeholder ids include a per-drop nonce so two `photos` folders\n// dropped back-to-back can't collide on the same queue identity, and\n// an index so two same-named folders inside one drop stay distinct.\nconst placeholderId = (name, index, nonce) =>\n  `__pending_${nonce}_${index}_${name}__`\n\nconst buildFolderPlaceholder = (name, index, nonce) => ({\n  fileId: placeholderId(name, index, nonce),\n  file: { name, type: '' },\n  relativePath: null,\n  folderId: null,\n  isDirectory: true,\n  status: RESOLVING\n})\n\nexport const addToUploadQueue =\n  (\n    entries,\n    dirID,\n    sharingState,\n    fileUploadedCallback,\n    queueCompletedCallback,\n    { client, maxFileCount, onLimitExceeded },\n    driveId,\n    addItems\n  ) =>\n  async dispatch => {\n    const folderRoots = collectFolderRoots(entries)\n    const dropNonce = `${Date.now().toString(36)}_${Math.random()\n      .toString(36)\n      .slice(2, 8)}`\n    const placeholders = folderRoots.map((name, i) =>\n      buildFolderPlaceholder(name, i, dropNonce)\n    )\n    const placeholderIds = placeholders.map(p => p.fileId)\n\n    const deferredEntries = entries.filter(isDeferredEntry)\n    const looseItems = entries\n      .filter(e => !isDeferredEntry(e) && e.file)\n      .map(e => makeFlatItem(e.file, dirID, null, dropNonce))\n    const allDropIds = [...placeholderIds, ...looseItems.map(i => i.fileId)]\n\n    const kickProcessing = () =>\n      dispatch(\n        processNextFile(\n          fileUploadedCallback,\n          queueCompletedCallback,\n          dirID,\n          sharingState,\n          { client },\n          driveId,\n          addItems\n        )\n      )\n\n    const failDrop = errStatus => {\n      for (const fileId of allDropIds) {\n        dispatch({ type: RECEIVE_UPLOAD_ERROR, fileId, status: errStatus })\n      }\n    }\n\n    // Mark every row for this drop failed and kick so processNextFile\n    // hits an empty PENDING set and runs onQueueEmpty → the upstream\n    // queueCompletedCallback surfaces the right toast.\n    const failAndKick = error => {\n      failDrop(classifyUploadError(error))\n      kickProcessing()\n    }\n\n    const initialItems = [...placeholders, ...looseItems]\n    if (initialItems.length > 0) {\n      dispatch({ type: ADD_TO_UPLOAD_QUEUE, files: initialItems })\n    }\n\n    try {\n      if (\n        typeof maxFileCount === 'number' &&\n        (await exceedsFileLimit(entries, maxFileCount))\n      ) {\n        // Modal is the user-facing feedback; the dropped rows stay in\n        // the tray as FAILED so the user sees what was rejected. We\n        // run the limit check before kicking processing, so loose\n        // files don't quietly get uploaded behind the modal. No\n        // kickProcessing here keeps queueCompletedCallback silent and\n        // avoids a redundant toast over the modal.\n        failDrop(FAILED)\n        if (typeof onLimitExceeded === 'function') onLimitExceeded()\n        return\n      }\n    } catch (error) {\n      failAndKick(error)\n      return\n    }\n\n    if (looseItems.length > 0) kickProcessing()\n    if (deferredEntries.length === 0) return\n\n    try {\n      const flatItems = await flattenEntries(\n        deferredEntries,\n        dirID,\n        client,\n        driveId,\n        dropNonce\n      )\n      dispatch({ type: RESOLVE_FOLDER_ITEMS, placeholderIds, files: flatItems })\n      kickProcessing()\n    } catch (error) {\n      failAndKick(error)\n    }\n  }\n\nexport const purgeUploadQueue = () => ({ type: PURGE_UPLOAD_QUEUE })\n\nexport const onQueueEmpty = callback => (dispatch, getState) => {\n  const safeCallback = typeof callback === 'function' ? callback : () => {}\n  const queue = getUploadQueue(getState())\n  // While folder placeholders are still being resolved, the queue isn't\n  // really empty; suppress the completion callback so the per-drop alert\n  // doesn't fire mid-flatten. The chain ends silently here; the\n  // addToUploadQueue thunk re-kicks processNextFile after the matching\n  // RESOLVE_FOLDER_ITEMS dispatch.\n  if (queue.some(i => i.status === RESOLVING)) return\n  const quotas = getQuotaErrors(queue)\n  const conflicts = getConflicts(queue)\n  const created = getCreated(queue)\n  const updated = getUpdated(queue)\n  const networkErrors = getNetworkErrors(queue)\n  const errors = getErrors(queue)\n  const unreadableErrors = getUnreadableErrors(queue)\n  const fileTooLargeErrors = getfileTooLargeErrors(queue)\n\n  const createdItems = created\n    .map(item => item.uploadedItem)\n    .filter(item => item && item._id)\n  const updatedItems = updated\n    .map(item => item.uploadedItem)\n    .filter(item => item && item._id)\n\n  return safeCallback({\n    createdItems,\n    quotas,\n    conflicts,\n    networkErrors,\n    errors,\n    unreadableErrors,\n    updatedItems,\n    fileTooLargeErrors\n  })\n}\n\n// selectors\nconst filterByStatus = (queue, status) => queue.filter(f => f.status === status)\nconst getConflicts = queue => filterByStatus(queue, CONFLICT)\nconst getErrors = queue => filterByStatus(queue, FAILED)\nconst getQuotaErrors = queue => filterByStatus(queue, QUOTA)\nconst getNetworkErrors = queue => filterByStatus(queue, NETWORK)\nconst getUnreadableErrors = queue => filterByStatus(queue, UNREADABLE)\nconst getCreated = queue => filterByStatus(queue, CREATED)\nconst getUpdated = queue => filterByStatus(queue, UPDATED)\nconst getfileTooLargeErrors = queue => filterByStatus(queue, ERR_MAX_FILE_SIZE)\n\nexport const getUploadQueue = state => state[SLUG].queue\n\nexport const getProcessed = state =>\n  getUploadQueue(state).filter(f => !IN_PROGRESS_STATUSES.includes(f.status))\n\nexport const getSuccessful = state => {\n  const queue = getUploadQueue(state)\n  return queue.filter(f => [CREATED, UPDATED].includes(f.status))\n}\n\nexport const selectors = {\n  getConflicts,\n  getErrors,\n  getQuotaErrors,\n  getNetworkErrors,\n  getUnreadableErrors,\n  getCreated,\n  getUpdated,\n  getProcessed,\n  getSuccessful\n}\n\n// DOM helpers\nexport const extractFilesEntries = items => {\n  let results = []\n  for (let i = 0; i < items.length; i += 1) {\n    const item = items[i]\n    if (item.webkitGetAsEntry != null && item.webkitGetAsEntry()) {\n      const entry = item.webkitGetAsEntry()\n      results.push({\n        file: item.getAsFile(),\n        isDirectory: entry.isDirectory === true,\n        entry\n      })\n    } else {\n      results.push({ file: item, isDirectory: false, entry: null })\n    }\n  }\n\n  if (results.length === 0) {\n    logger.warn('Upload module files entries extraction: no file entry')\n  }\n\n  return results\n}\n\n/**\n * Recursively count all files inside a directory entry.\n *\n * @param {FileSystemDirectoryEntry} directoryEntry - A directory obtained from the drag-and-drop FileSystem API\n * @returns {Promise<number>} Total number of files (excluding sub-directories themselves)\n */\nconst countDirectoryFiles = async directoryEntry => {\n  const reader = directoryEntry.createReader()\n  const childEntries = await readAllEntries(reader)\n  let count = 0\n  for (const entry of childEntries) {\n    if (entry.isFile) {\n      count += 1\n    } else if (entry.isDirectory) {\n      count += await countDirectoryFiles(entry)\n    }\n  }\n  return count\n}\n\n/**\n * Check whether the total number of files in the given entries exceeds\n * the provided limit. Directories are counted in parallel for speed.\n * Flat files are checked first to avoid directory traversal when possible.\n *\n * @param {Array<{file: File, isDirectory: boolean, entry: FileSystemEntry|null}>} entries - Extracted entries from {@link extractFilesEntries}\n * @param {number} limit - Maximum number of files allowed\n * @returns {Promise<boolean>} `true` if the file count exceeds the limit\n */\nexport const exceedsFileLimit = async (entries, limit) => {\n  const fileCount = entries.filter(e => !e.isDirectory || !e.entry).length\n  const directories = entries.filter(e => e.isDirectory && e.entry)\n\n  if (fileCount > limit) return true\n\n  const dirCounts = await Promise.all(\n    directories.map(e => countDirectoryFiles(e.entry))\n  )\n\n  let count = fileCount\n  for (const dirCount of dirCounts) {\n    count += dirCount\n    if (count > limit) return true\n  }\n\n  return false\n}\n"
  },
  {
    "path": "src/modules/upload/index.spec.js",
    "content": "import {\n  processNextFile,\n  selectors,\n  queue,\n  overwriteFile,\n  uploadProgress,\n  extractFilesEntries,\n  exceedsFileLimit,\n  flattenEntries,\n  addToUploadQueue,\n  onQueueEmpty\n} from './index'\n\nimport logger from '@/lib/logger'\nimport { CozyFile } from '@/models'\n\njest.mock('cozy-doctypes')\n\nconst createFileSpy = jest.fn().mockName('createFile')\nconst createDirectorySpy = jest.fn().mockName('createDirectory')\nconst statByPathSpy = jest.fn().mockName('statByPath')\nconst updateFileSpy = jest.fn().mockName('updateFile')\nconst fakeClient = {\n  collection: () => ({\n    createFile: createFileSpy,\n    createDirectory: createDirectorySpy,\n    statByPath: statByPathSpy,\n    updateFile: updateFileSpy\n  }),\n  query: jest.fn()\n}\n\nCozyFile.getFullpath.mockResolvedValue('/my-dir/mydoc.odt')\n\ndescribe('processNextFile function', () => {\n  const fileUploadedCallbackSpy = jest.fn()\n  const queueCompletedCallbackSpy = jest.fn()\n  const dirId = 'my-dir'\n  const dispatchSpy = jest.fn(x => x)\n  const file = new File(['foo'], 'my-doc.odt')\n  const sharingState = {\n    sharedPaths: []\n  }\n  fakeClient.query.mockResolvedValueOnce(null)\n\n  it('should handle an empty queue', async () => {\n    const getState = () => ({\n      upload: {\n        queue: []\n      }\n    })\n    const asyncProcess = processNextFile(\n      fileUploadedCallbackSpy,\n      queueCompletedCallbackSpy,\n      dirId,\n      sharingState,\n      { client: fakeClient }\n    )\n    const result = await asyncProcess(dispatchSpy, getState, {\n      client: fakeClient\n    })\n    result(dispatchSpy, getState)\n    expect(queueCompletedCallbackSpy).toHaveBeenCalledWith({\n      createdItems: [],\n      quotas: [],\n      conflicts: [],\n      networkErrors: [],\n      errors: [],\n      unreadableErrors: [],\n      updatedItems: [],\n      fileTooLargeErrors: []\n    })\n  })\n\n  it('should process files in the queue', async () => {\n    const getState = () => ({\n      upload: {\n        queue: [\n          {\n            status: 'pending',\n            file,\n            entry: '',\n            isDirectory: false\n          }\n        ]\n      }\n    })\n    createFileSpy.mockResolvedValue({\n      data: {\n        file\n      }\n    })\n    const asyncProcess = processNextFile(\n      fileUploadedCallbackSpy,\n      queueCompletedCallbackSpy,\n      dirId,\n      sharingState,\n      { client: fakeClient }\n    )\n    await asyncProcess(dispatchSpy, getState)\n    expect(dispatchSpy).toHaveBeenCalledWith({\n      type: 'UPLOAD_FILE',\n      file\n    })\n    expect(createFileSpy).toHaveBeenCalledWith(file, {\n      dirId: 'my-dir',\n      onUploadProgress: expect.any(Function)\n    })\n  })\n\n  it('should process a file in conflict', async () => {\n    const getState = () => ({\n      upload: {\n        queue: [\n          {\n            status: 'pending',\n            file,\n            entry: '',\n            isDirectory: false\n          }\n        ]\n      }\n    })\n    createFileSpy.mockRejectedValue({\n      status: 409,\n      title: 'Conflict',\n      detail: 'file already exists',\n      source: {}\n    })\n\n    statByPathSpy.mockResolvedValue({\n      data: {\n        dir_id: 'my-dir',\n        id: 'b552a167-1aa4'\n      }\n    })\n\n    updateFileSpy.mockResolvedValue({ data: file })\n\n    const asyncProcess = processNextFile(\n      fileUploadedCallbackSpy,\n      queueCompletedCallbackSpy,\n      dirId,\n      sharingState,\n      { client: fakeClient }\n    )\n    await asyncProcess(dispatchSpy, getState)\n\n    expect(dispatchSpy).toHaveBeenNthCalledWith(1, {\n      type: 'UPLOAD_FILE',\n      file\n    })\n    expect(createFileSpy).toHaveBeenCalledWith(file, {\n      dirId: 'my-dir',\n      onUploadProgress: expect.any(Function)\n    })\n\n    expect(updateFileSpy).toHaveBeenCalledWith(file, {\n      fileId: 'b552a167-1aa4',\n      onUploadProgress: expect.any(Function)\n    })\n\n    expect(fileUploadedCallbackSpy).toHaveBeenCalledWith(file)\n\n    expect(dispatchSpy).toHaveBeenNthCalledWith(2, {\n      type: 'RECEIVE_UPLOAD_SUCCESS',\n      file,\n      isUpdate: true,\n      uploadedItem: file\n    })\n  })\n\n  it('should handle an error during overwrite', async () => {\n    logger.error = jest.fn()\n    const getState = () => ({\n      upload: {\n        queue: [\n          {\n            status: 'pending',\n            file,\n            entry: '',\n            isDirectory: false\n          }\n        ]\n      }\n    })\n    createFileSpy.mockRejectedValue({\n      status: 409,\n      title: 'Conflict',\n      detail: 'file already exists',\n      source: {}\n    })\n\n    statByPathSpy.mockResolvedValue({\n      data: {\n        id: 'b552a167-1aa4'\n      }\n    })\n\n    updateFileSpy.mockRejectedValue({ status: 413 })\n\n    const asyncProcess = processNextFile(\n      fileUploadedCallbackSpy,\n      queueCompletedCallbackSpy,\n      dirId,\n      sharingState,\n      { client: fakeClient }\n    )\n    await asyncProcess(dispatchSpy, getState, { client: fakeClient })\n\n    expect(fileUploadedCallbackSpy).not.toHaveBeenCalled()\n\n    expect(dispatchSpy).toHaveBeenNthCalledWith(2, {\n      file,\n      status: 'quota',\n      type: 'RECEIVE_UPLOAD_ERROR'\n    })\n  })\n\n  it('should handle an error during upload', async () => {\n    logger.warn = jest.fn()\n    const getState = () => ({\n      upload: {\n        queue: [\n          {\n            status: 'pending',\n            file,\n            entry: '',\n            isDirectory: false\n          }\n        ]\n      }\n    })\n    createFileSpy.mockRejectedValue({\n      status: 413,\n      title: 'QUOTA',\n      detail: 'QUOTA',\n      source: {}\n    })\n\n    const asyncProcess = processNextFile(\n      fileUploadedCallbackSpy,\n      queueCompletedCallbackSpy,\n      dirId,\n      sharingState,\n      { client: fakeClient }\n    )\n    await asyncProcess(dispatchSpy, getState, { client: fakeClient })\n\n    expect(fileUploadedCallbackSpy).not.toHaveBeenCalled()\n\n    expect(dispatchSpy).toHaveBeenNthCalledWith(2, {\n      file,\n      status: 'quota',\n      type: 'RECEIVE_UPLOAD_ERROR'\n    })\n  })\n\n  it('should classify NotFoundError (browser FileSystem API) as unreadable', async () => {\n    logger.warn = jest.fn()\n    const getState = () => ({\n      upload: {\n        queue: [\n          {\n            status: 'pending',\n            file,\n            entry: '',\n            isDirectory: false\n          }\n        ]\n      }\n    })\n    const notFoundError = new Error(\n      'A requested file or directory could not be found at the time an operation was processed.'\n    )\n    notFoundError.name = 'NotFoundError'\n    createFileSpy.mockRejectedValue(notFoundError)\n\n    const asyncProcess = processNextFile(\n      fileUploadedCallbackSpy,\n      queueCompletedCallbackSpy,\n      dirId,\n      sharingState,\n      { client: fakeClient }\n    )\n    await asyncProcess(dispatchSpy, getState, { client: fakeClient })\n\n    expect(fileUploadedCallbackSpy).not.toHaveBeenCalled()\n    expect(dispatchSpy).toHaveBeenNthCalledWith(2, {\n      file,\n      status: 'unreadable',\n      type: 'RECEIVE_UPLOAD_ERROR'\n    })\n  })\n})\n\ndescribe('selectors', () => {\n  const queue = [\n    { status: 'created' },\n    { status: 'updated' },\n    { status: 'conflict' },\n    { status: 'failed' },\n    { status: 'quota' },\n    { status: 'network' },\n    { status: 'pending' }\n  ]\n\n  describe('getCreated selector', () => {\n    it('should return all uploaded items', () => {\n      const result = selectors.getCreated(queue)\n      expect(result).toEqual([\n        {\n          status: 'created'\n        }\n      ])\n    })\n  })\n\n  describe('getUpdated selector', () => {\n    it('should return all updated items', () => {\n      const result = selectors.getUpdated(queue)\n      expect(result).toEqual([\n        {\n          status: 'updated'\n        }\n      ])\n    })\n  })\n\n  describe('getSuccessful selector', () => {\n    it('should return all successful items', () => {\n      const queue = [\n        { id: '1', status: 'created' },\n        { id: '2', status: 'quota' },\n        { id: '3', status: 'conflict' },\n        { id: '4', status: 'updated' },\n        { id: '5', status: 'failed' },\n        { id: '6', status: 'updated' }\n      ]\n      const state = {\n        upload: {\n          queue\n        }\n      }\n      const result = selectors.getSuccessful(state)\n      expect(result).toEqual([\n        { id: '1', status: 'created' },\n        { id: '4', status: 'updated' },\n        { id: '6', status: 'updated' }\n      ])\n    })\n  })\n})\n\ndescribe('queue reducer', () => {\n  const buildItem = name => ({\n    fileId: name,\n    status: 'pending',\n    file: { name },\n    progress: null\n  })\n  const state = [\n    buildItem('doc1.odt'),\n    buildItem('doc2.odt'),\n    buildItem('doc3.odt')\n  ]\n  it('should be empty (initial state)', () => {\n    const result = queue(undefined, {})\n    expect(result).toEqual([])\n  })\n\n  it('should handle PURGE_UPLOAD_QUEUE action type', () => {\n    const action = {\n      type: 'PURGE_UPLOAD_QUEUE'\n    }\n    const state = [{ status: 'created', id: '1' }]\n    const result = queue(state, action)\n    expect(result).toEqual([])\n  })\n\n  it('drops RESOLVE_FOLDER_ITEMS files when no placeholder remains in state', () => {\n    const stateIn = [buildItem('unrelated.odt')]\n    const result = queue(stateIn, {\n      type: 'RESOLVE_FOLDER_ITEMS',\n      placeholderIds: ['__pending_abc_0_photos__'],\n      files: [\n        { fileId: 'photos/a.jpg', file: { name: 'a.jpg' }, folderId: 'dir-1' }\n      ]\n    })\n    // Same-reference return so connected components don't re-render.\n    expect(result).toBe(stateIn)\n  })\n\n  it('replaces matched placeholders with files on RESOLVE_FOLDER_ITEMS', () => {\n    const placeholder = {\n      fileId: '__pending_abc_0_photos__',\n      status: 'resolving',\n      file: { name: 'photos' },\n      progress: null\n    }\n    const result = queue([placeholder], {\n      type: 'RESOLVE_FOLDER_ITEMS',\n      placeholderIds: ['__pending_abc_0_photos__'],\n      files: [\n        { fileId: 'photos/a.jpg', file: { name: 'a.jpg' }, folderId: 'dir-1' }\n      ]\n    })\n    expect(result).toHaveLength(1)\n    expect(result[0]).toMatchObject({\n      fileId: 'photos/a.jpg',\n      status: 'pending'\n    })\n  })\n\n  it('should handle UPLOAD_FILE action type', () => {\n    const action = {\n      type: 'UPLOAD_FILE',\n      fileId: 'doc1.odt'\n    }\n    const result = queue(state, action)\n    expect(result[0]).toMatchObject({ fileId: 'doc1.odt', status: 'loading' })\n    expect(result[1]).toMatchObject({ fileId: 'doc2.odt', status: 'pending' })\n    expect(result[2]).toMatchObject({ fileId: 'doc3.odt', status: 'pending' })\n  })\n\n  it('should handle RECEIVE_UPLOAD_SUCCESS action type', () => {\n    const action = {\n      type: 'RECEIVE_UPLOAD_SUCCESS',\n      fileId: 'doc3.odt'\n    }\n    const result = queue(state, action)\n    expect(result[2]).toMatchObject({ fileId: 'doc3.odt', status: 'created' })\n  })\n\n  it('should handle RECEIVE_UPLOAD_SUCCESS action type (update)', () => {\n    const action = {\n      type: 'RECEIVE_UPLOAD_SUCCESS',\n      fileId: 'doc3.odt',\n      isUpdate: true\n    }\n    const result = queue(state, action)\n    expect(result[2]).toMatchObject({ fileId: 'doc3.odt', status: 'updated' })\n  })\n\n  it('should handle RECEIVE_UPLOAD_ERROR action type', () => {\n    const action = {\n      type: 'RECEIVE_UPLOAD_ERROR',\n      fileId: 'doc2.odt',\n      status: 'conflict'\n    }\n    const result = queue(state, action)\n    expect(result[1]).toMatchObject({ fileId: 'doc2.odt', status: 'conflict' })\n  })\n\n  describe('progress action', () => {\n    const fileId = 'doc1.odt'\n    const date1 = 1000\n    const date2 = 2000\n    const event1 = { loaded: 100, total: 400 }\n    const event2 = { loaded: 200, total: 400 }\n\n    it('should handle UPLOAD_PROGRESS', () => {\n      const result = queue(state, uploadProgress(fileId, event1, date1))\n      expect(result[0].progress).toEqual({\n        lastUpdated: date1,\n        remainingTime: null,\n        speed: null,\n        loaded: event1.loaded,\n        total: event1.total\n      })\n      expect(result[1].progress).toBe(null)\n    })\n\n    it('should compute speed and remaining time', () => {\n      const result = queue(state, uploadProgress(fileId, event1, date1))\n      expect(result[0].progress.remainingTime).toBe(null)\n      const result2 = queue(result, uploadProgress(fileId, event2, date2))\n      expect(result2[0].progress).toEqual({\n        lastUpdated: expect.any(Number),\n        loaded: 200,\n        remainingTime: 2,\n        speed: 100,\n        total: 400\n      })\n    })\n\n    it('should handle upload error', () => {\n      const result = queue(state, uploadProgress(fileId, event1, date1))\n      const result2 = queue(result, uploadProgress(fileId, event2, date2))\n      const result3 = queue(result2, {\n        type: 'RECEIVE_UPLOAD_ERROR',\n        fileId\n      })\n      expect(result3[0].progress).toEqual(null)\n    })\n  })\n})\n\n// Helpers to mock browser FileSystem API objects\nconst createMockFileEntry = (name, content = '') => ({\n  isFile: true,\n  isDirectory: false,\n  name,\n  file: resolve => resolve(new File([content], name))\n})\n\n// A file entry whose file() rejects, simulating Windows long-path\n// NotFoundError surfacing during File extraction.\nconst createUnreadableFileEntry = name => ({\n  isFile: true,\n  isDirectory: false,\n  name,\n  file: (_resolve, reject) => {\n    const err = new Error('vanished')\n    err.name = 'NotFoundError'\n    reject(err)\n  }\n})\n\nconst createMockDirEntry = (name, children) => ({\n  isFile: false,\n  isDirectory: true,\n  name,\n  createReader: () => {\n    let read = false\n    return {\n      readEntries: resolve => {\n        if (!read) {\n          read = true\n          resolve(children)\n        } else {\n          resolve([])\n        }\n      }\n    }\n  }\n})\n\n// A directory entry whose readEntries rejects, simulating Windows\n// long-path NotFoundError when enumerating a deep folder.\nconst createUnreadableDirEntry = name => ({\n  isFile: false,\n  isDirectory: true,\n  name,\n  createReader: () => ({\n    readEntries: (_resolve, reject) => {\n      const err = new Error('path too long')\n      err.name = 'NotFoundError'\n      reject(err)\n    }\n  })\n})\n\nconst createBrokenDirEntry = name => ({\n  isFile: false,\n  isDirectory: true,\n  name,\n  createReader: () => ({\n    readEntries: (_resolve, reject) => reject(new Error('permission denied'))\n  })\n})\n\ndescribe('extractFilesEntries', () => {\n  it('should extract plain File objects', () => {\n    const files = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')]\n    const result = extractFilesEntries(files)\n    expect(result).toHaveLength(2)\n    expect(result[0]).toEqual({\n      file: files[0],\n      isDirectory: false,\n      entry: null\n    })\n  })\n\n  it('should extract DataTransferItem with file entry', () => {\n    const file = new File(['a'], 'a.txt')\n    const fileEntry = { isFile: true, isDirectory: false }\n    const items = [\n      {\n        webkitGetAsEntry: () => fileEntry,\n        getAsFile: () => file\n      }\n    ]\n    const result = extractFilesEntries(items)\n    expect(result).toHaveLength(1)\n    expect(result[0]).toEqual({\n      file,\n      isDirectory: false,\n      entry: fileEntry\n    })\n  })\n\n  it('should extract DataTransferItem with directory entry', () => {\n    const dirEntry = { isFile: false, isDirectory: true }\n    const items = [\n      {\n        webkitGetAsEntry: () => dirEntry,\n        getAsFile: () => null\n      }\n    ]\n    const result = extractFilesEntries(items)\n    expect(result).toHaveLength(1)\n    expect(result[0]).toEqual({\n      file: null,\n      isDirectory: true,\n      entry: dirEntry\n    })\n  })\n\n  it('should handle empty items', () => {\n    const result = extractFilesEntries([])\n    expect(result).toHaveLength(0)\n  })\n})\n\ndescribe('exceedsFileLimit', () => {\n  it('should return false when flat files are under the limit', async () => {\n    const entries = [\n      { file: new File(['a'], 'a.txt'), isDirectory: false, entry: null },\n      { file: new File(['b'], 'b.txt'), isDirectory: false, entry: null },\n      { file: new File(['c'], 'c.txt'), isDirectory: false, entry: null }\n    ]\n    expect(await exceedsFileLimit(entries, 500)).toBe(false)\n  })\n\n  it('should return false when total including directories is under the limit', async () => {\n    const dirEntry = createMockDirEntry('photos', [\n      createMockFileEntry('img1.jpg'),\n      createMockFileEntry('img2.jpg')\n    ])\n    const entries = [\n      { file: null, isDirectory: true, entry: dirEntry },\n      { file: new File(['a'], 'doc.txt'), isDirectory: false, entry: null }\n    ]\n    expect(await exceedsFileLimit(entries, 500)).toBe(false)\n  })\n\n  it('should count files in nested directories', async () => {\n    const subDir = createMockDirEntry('sub', [createMockFileEntry('deep.txt')])\n    const topDir = createMockDirEntry('top', [\n      createMockFileEntry('shallow.txt'),\n      subDir\n    ])\n    const entries = [{ file: null, isDirectory: true, entry: topDir }]\n    expect(await exceedsFileLimit(entries, 1)).toBe(true)\n    expect(await exceedsFileLimit(entries, 2)).toBe(false)\n  })\n\n  it('should return false for empty directories', async () => {\n    const emptyDir = createMockDirEntry('empty', [])\n    const entries = [\n      { file: null, isDirectory: true, entry: emptyDir },\n      { file: new File(['a'], 'a.txt'), isDirectory: false, entry: null }\n    ]\n    expect(await exceedsFileLimit(entries, 500)).toBe(false)\n  })\n\n  it('should return false for empty entries', async () => {\n    expect(await exceedsFileLimit([], 500)).toBe(false)\n  })\n\n  it('should return true when flat files alone exceed the limit', async () => {\n    const entries = Array.from({ length: 600 }, (_, i) => ({\n      file: new File([''], `file${i}.txt`),\n      isDirectory: false,\n      entry: null\n    }))\n    expect(await exceedsFileLimit(entries, 500)).toBe(true)\n  })\n\n  it('should return false when files across multiple directories are under the limit', async () => {\n    const dir1 = createMockDirEntry(\n      'dir1',\n      Array.from({ length: 10 }, (_, i) => createMockFileEntry(`a${i}.txt`))\n    )\n    const dir2 = createMockDirEntry(\n      'dir2',\n      Array.from({ length: 15 }, (_, i) => createMockFileEntry(`b${i}.txt`))\n    )\n    const entries = [\n      { file: null, isDirectory: true, entry: dir1 },\n      { file: null, isDirectory: true, entry: dir2 },\n      { file: new File([''], 'root.txt'), isDirectory: false, entry: null }\n    ]\n    expect(await exceedsFileLimit(entries, 500)).toBe(false)\n  })\n\n  it('should return true when cumulative count across directories exceeds the limit', async () => {\n    const dir1 = createMockDirEntry(\n      'dir1',\n      Array.from({ length: 300 }, (_, i) => createMockFileEntry(`a${i}.txt`))\n    )\n    const dir2 = createMockDirEntry(\n      'dir2',\n      Array.from({ length: 300 }, (_, i) => createMockFileEntry(`b${i}.txt`))\n    )\n    const entries = [\n      { file: null, isDirectory: true, entry: dir1 },\n      { file: null, isDirectory: true, entry: dir2 }\n    ]\n    expect(await exceedsFileLimit(entries, 500)).toBe(true)\n  })\n})\n\ndescribe('overwriteFile function', () => {\n  beforeEach(() => {\n    statByPathSpy.mockReset()\n    updateFileSpy.mockReset()\n  })\n  it('should update the io.cozy.files', async () => {\n    updateFileSpy.mockResolvedValue({\n      data: {\n        id: 'b7cb22be72d2',\n        type: 'io.cozy.files',\n        attributes: {\n          type: 'file',\n          name: 'mydoc.odt'\n        }\n      }\n    })\n    statByPathSpy.mockResolvedValue({\n      data: {\n        id: 'b7cb22be72d2',\n        dir_id: '972bc693-f015'\n      }\n    })\n    const file = new File([''], 'mydoc.odt')\n    const onUploadProgress = jest.fn()\n    const result = await overwriteFile(fakeClient, file, '/parent/mydoc.odt', {\n      onUploadProgress\n    })\n    expect(updateFileSpy).toHaveBeenCalledWith(file, {\n      fileId: 'b7cb22be72d2',\n      onUploadProgress\n    })\n    expect(result).toEqual({\n      id: 'b7cb22be72d2',\n      type: 'io.cozy.files',\n      attributes: {\n        type: 'file',\n        name: 'mydoc.odt'\n      }\n    })\n  })\n})\n\ndescribe('flattenEntries', () => {\n  beforeEach(() => {\n    createDirectorySpy.mockReset()\n    createFileSpy.mockReset()\n    statByPathSpy.mockReset()\n    updateFileSpy.mockReset()\n    CozyFile.getFullpath.mockReset()\n    createDirectorySpy.mockImplementation(async ({ name }) => ({\n      data: { id: `dir-${name}`, name, type: 'directory' }\n    }))\n  })\n\n  it('should flatten a dropped directory entry into per-file items with relative paths', async () => {\n    const directoryEntry = createMockDirEntry('photos', [\n      createMockFileEntry('img1.jpg'),\n      createMockFileEntry('img2.jpg')\n    ])\n    const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]\n\n    const result = await flattenEntries(entries, 'root', fakeClient, null)\n\n    expect(createDirectorySpy).toHaveBeenCalledWith({\n      name: 'photos',\n      dirId: 'root'\n    })\n    expect(result).toHaveLength(2)\n    expect(result[0]).toMatchObject({\n      fileId: 'photos/img1.jpg',\n      relativePath: 'photos/img1.jpg',\n      folderId: 'dir-photos'\n    })\n  })\n\n  it('should reuse an existing folder when createDirectory returns 409', async () => {\n    createDirectorySpy.mockReset()\n    createDirectorySpy.mockRejectedValueOnce({ status: 409 })\n    CozyFile.getFullpath.mockResolvedValueOnce('/root/photos')\n    statByPathSpy.mockResolvedValueOnce({\n      data: { type: 'directory', id: 'existing-photos' }\n    })\n\n    const directoryEntry = createMockDirEntry('photos', [\n      createMockFileEntry('img.jpg')\n    ])\n    const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]\n\n    const result = await flattenEntries(entries, 'root', fakeClient, null)\n\n    expect(result).toHaveLength(1)\n    expect(result[0]).toMatchObject({\n      fileId: 'photos/img.jpg',\n      relativePath: 'photos/img.jpg',\n      folderId: 'existing-photos'\n    })\n  })\n\n  it('should recurse into nested directories and carry the relative path', async () => {\n    const innerDir = createMockDirEntry('2024', [\n      createMockFileEntry('ski.jpg')\n    ])\n    const directoryEntry = createMockDirEntry('photos', [innerDir])\n    const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]\n\n    const result = await flattenEntries(entries, 'root', fakeClient, null)\n\n    expect(result).toHaveLength(1)\n    expect(result[0]).toMatchObject({\n      fileId: 'photos/2024/ski.jpg',\n      relativePath: 'photos/2024/ski.jpg',\n      folderId: 'dir-2024'\n    })\n  })\n\n  it('should place loose files under the root directory without a relative path', async () => {\n    const plainFile = new File(['a'], 'note.txt')\n    const entries = [{ file: plainFile, isDirectory: false, entry: null }]\n\n    const result = await flattenEntries(entries, 'root', fakeClient, null)\n\n    expect(createDirectorySpy).not.toHaveBeenCalled()\n    expect(result).toHaveLength(1)\n    expect(result[0]).toMatchObject({\n      fileId: 'note.txt',\n      relativePath: null,\n      folderId: 'root'\n    })\n  })\n\n  it('still creates the ancestor folders when the deepest one is unreadable', async () => {\n    // We can't enumerate `e`, but every ancestor (and `e` itself)\n    // should still be created server-side so the user can drop the\n    // missing files into the right place by hand. A single UNREADABLE\n    // row flags the folder whose contents we couldn't read.\n    const e = createUnreadableDirEntry('e')\n    const d = createMockDirEntry('d', [e])\n    const c = createMockDirEntry('c', [d])\n    const b = createMockDirEntry('b', [c])\n    const a = createMockDirEntry('a', [b])\n    const entries = [{ file: null, isDirectory: true, entry: a }]\n\n    const result = await flattenEntries(entries, 'root', fakeClient, null)\n\n    const createdNames = createDirectorySpy.mock.calls.map(c => c[0].name)\n    expect(createdNames).toEqual(['a', 'b', 'c', 'd', 'e'])\n    expect(result).toHaveLength(1)\n    expect(result[0]).toMatchObject({\n      relativePath: 'a/b/c/d/e',\n      folderId: null,\n      status: 'unreadable',\n      isDirectory: true\n    })\n  })\n\n  it('creates empty folders even when they contain no files', async () => {\n    // Empty subfolders are part of the dropped structure; they should\n    // land in Drive verbatim so the tree matches what was dropped.\n    const empty = createMockDirEntry('empty', [])\n    const top = createMockDirEntry('top', [empty])\n    const entries = [{ file: null, isDirectory: true, entry: top }]\n\n    const result = await flattenEntries(entries, 'root', fakeClient, null)\n\n    const createdNames = createDirectorySpy.mock.calls.map(c => c[0].name)\n    expect(createdNames).toEqual(['top', 'empty'])\n    expect(result).toEqual([])\n  })\n\n  it('classifies non-NotFoundError read failures as failed, not unreadable', async () => {\n    const broken = createBrokenDirEntry('broken')\n    const top = createMockDirEntry('top', [broken])\n    const entries = [{ file: null, isDirectory: true, entry: top }]\n\n    const result = await flattenEntries(entries, 'root', fakeClient, null)\n\n    const createdNames = createDirectorySpy.mock.calls.map(c => c[0].name)\n    expect(createdNames).toEqual(['top', 'broken'])\n    expect(result).toHaveLength(1)\n    expect(result[0]).toMatchObject({\n      relativePath: 'top/broken',\n      status: 'failed'\n    })\n  })\n\n  it('uploads readable siblings and surfaces one row per unreadable subtree', async () => {\n    // top/\n    //   ok.txt              <- readable, should upload\n    //   broken/             <- readEntries fails, one unreadable row\n    //   nested/\n    //     deep.txt          <- readable, should upload\n    //     ghost.bin         <- entry.file() fails, one unreadable row\n    const broken = createUnreadableDirEntry('broken')\n    const nested = createMockDirEntry('nested', [\n      createMockFileEntry('deep.txt'),\n      createUnreadableFileEntry('ghost.bin')\n    ])\n    const top = createMockDirEntry('top', [\n      createMockFileEntry('ok.txt'),\n      broken,\n      nested\n    ])\n    const entries = [{ file: null, isDirectory: true, entry: top }]\n\n    const result = await flattenEntries(entries, 'root', fakeClient, null)\n\n    const createdNames = createDirectorySpy.mock.calls.map(c => c[0].name)\n    expect(createdNames).toEqual(['top', 'broken', 'nested'])\n\n    const readable = result.filter(r => r.status !== 'unreadable')\n    const unreadable = result.filter(r => r.status === 'unreadable')\n\n    expect(readable).toHaveLength(2)\n    expect(readable.map(r => r.relativePath).sort()).toEqual([\n      'top/nested/deep.txt',\n      'top/ok.txt'\n    ])\n\n    expect(unreadable).toHaveLength(2)\n    expect(unreadable.map(r => r.relativePath).sort()).toEqual([\n      'top/broken',\n      'top/nested/ghost.bin'\n    ])\n\n    const brokenRow = unreadable.find(r => r.relativePath === 'top/broken')\n    const ghostRow = unreadable.find(\n      r => r.relativePath === 'top/nested/ghost.bin'\n    )\n    expect(brokenRow.isDirectory).toBe(true)\n    expect(ghostRow.isDirectory).toBe(false)\n  })\n\n  it('should route react-dropzone File.path entries through the folder cache', async () => {\n    const nested = new File(['a'], 'a.txt')\n    nested.path = '/album/2024/a.txt'\n    const loose = new File(['b'], 'b.txt')\n    loose.path = '/b.txt'\n    const entries = [\n      { file: nested, isDirectory: false, entry: null },\n      { file: loose, isDirectory: false, entry: null }\n    ]\n\n    const result = await flattenEntries(entries, 'root', fakeClient, null)\n\n    expect(createDirectorySpy).toHaveBeenCalledTimes(2)\n    expect(createDirectorySpy).toHaveBeenNthCalledWith(1, {\n      name: 'album',\n      dirId: 'root'\n    })\n    expect(createDirectorySpy).toHaveBeenNthCalledWith(2, {\n      name: '2024',\n      dirId: 'dir-album'\n    })\n    expect(result).toHaveLength(2)\n    expect(result[0]).toMatchObject({\n      fileId: 'album/2024/a.txt',\n      relativePath: 'album/2024/a.txt',\n      folderId: 'dir-2024'\n    })\n    expect(result[1]).toMatchObject({\n      fileId: 'b.txt',\n      relativePath: null,\n      folderId: 'root'\n    })\n  })\n})\n\ndescribe('addToUploadQueue placeholder flow', () => {\n  beforeEach(() => {\n    createDirectorySpy.mockReset()\n    createFileSpy.mockReset()\n    statByPathSpy.mockReset()\n    updateFileSpy.mockReset()\n    CozyFile.getFullpath.mockReset()\n    createDirectorySpy.mockImplementation(async ({ name }) => ({\n      data: { id: `dir-${name}`, name, type: 'directory' }\n    }))\n  })\n\n  const runThunk = async (\n    thunk,\n    getState = () => ({ upload: { queue: [] } })\n  ) => {\n    const dispatched = []\n    const pending = []\n    const dispatch = jest.fn(action => {\n      dispatched.push(action)\n      if (typeof action !== 'function') return undefined\n      const result = action(dispatch, getState)\n      if (result && typeof result.then === 'function') pending.push(result)\n      return result\n    })\n    await thunk(dispatch, getState)\n    // Awaited thunks may dispatch further thunks, so drain in a loop\n    // until no new promises are queued.\n    while (pending.length) await Promise.all(pending.splice(0))\n    return dispatched.filter(a => typeof a !== 'function')\n  }\n\n  it('emits a placeholder for each top-level folder and replaces it after flatten', async () => {\n    const directoryEntry = createMockDirEntry('photos', [\n      createMockFileEntry('img1.jpg'),\n      createMockFileEntry('img2.jpg')\n    ])\n    const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]\n\n    const actions = await runThunk(\n      addToUploadQueue(\n        entries,\n        'root',\n        {},\n        () => null,\n        () => null,\n        { client: fakeClient },\n        null,\n        () => null\n      )\n    )\n\n    const adds = actions.filter(a => a.type === 'ADD_TO_UPLOAD_QUEUE')\n    const resolves = actions.filter(a => a.type === 'RESOLVE_FOLDER_ITEMS')\n    expect(adds).toHaveLength(1)\n    expect(adds[0].files).toEqual([\n      expect.objectContaining({\n        fileId: expect.stringMatching(/^__pending_.+_0_photos__$/),\n        status: 'resolving'\n      })\n    ])\n    expect(resolves).toHaveLength(1)\n    expect(resolves[0].placeholderIds).toEqual([\n      expect.stringMatching(/^__pending_.+_0_photos__$/)\n    ])\n    expect(resolves[0].files).toHaveLength(2)\n    expect(resolves[0].files[0]).toMatchObject({\n      fileId: expect.stringMatching(/^.+_photos\\/img1\\.jpg$/),\n      relativePath: 'photos/img1.jpg'\n    })\n  })\n\n  it('skips placeholders for plain file drops', async () => {\n    const plainFile = new File(['a'], 'note.txt')\n    const entries = [{ file: plainFile, isDirectory: false, entry: null }]\n\n    const actions = await runThunk(\n      addToUploadQueue(\n        entries,\n        'root',\n        {},\n        () => null,\n        () => null,\n        { client: fakeClient },\n        null,\n        () => null\n      )\n    )\n\n    const types = actions.map(a => a.type)\n    expect(types).toContain('ADD_TO_UPLOAD_QUEUE')\n    expect(types).not.toContain('RESOLVE_FOLDER_ITEMS')\n  })\n\n  it('replaces the placeholder with one unreadable row when an inner folder cannot be read', async () => {\n    // Asserts the placeholder is resolved (not stuck on RESOLVING) by\n    // an UNREADABLE row — otherwise the queue would silently swallow\n    // the drop and the alert pipeline never fires.\n    const e = createUnreadableDirEntry('e')\n    const d = createMockDirEntry('d', [e])\n    const c = createMockDirEntry('c', [d])\n    const b = createMockDirEntry('b', [c])\n    const a = createMockDirEntry('a', [b])\n    const entries = [{ file: null, isDirectory: true, entry: a }]\n\n    const actions = await runThunk(\n      addToUploadQueue(\n        entries,\n        'root',\n        {},\n        () => null,\n        () => null,\n        { client: fakeClient },\n        null,\n        () => null\n      )\n    )\n\n    const createdNames = createDirectorySpy.mock.calls.map(c => c[0].name)\n    expect(createdNames).toEqual(['a', 'b', 'c', 'd', 'e'])\n    const resolves = actions.filter(a => a.type === 'RESOLVE_FOLDER_ITEMS')\n    expect(resolves).toHaveLength(1)\n    expect(resolves[0].placeholderIds).toEqual([\n      expect.stringMatching(/^__pending_.+_0_a__$/)\n    ])\n    expect(resolves[0].files).toHaveLength(1)\n    expect(resolves[0].files[0]).toMatchObject({\n      relativePath: 'a/b/c/d/e',\n      status: 'unreadable'\n    })\n  })\n\n  it('marks placeholders as failed if flatten throws', async () => {\n    const directoryEntry = createMockDirEntry('photos', [\n      createMockFileEntry('img.jpg')\n    ])\n    createDirectorySpy.mockReset()\n    createDirectorySpy.mockRejectedValue(new Error('server down'))\n    const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]\n\n    const actions = await runThunk(\n      addToUploadQueue(\n        entries,\n        'root',\n        {},\n        () => null,\n        () => null,\n        { client: fakeClient },\n        null,\n        () => null\n      )\n    )\n\n    const errors = actions.filter(a => a.type === 'RECEIVE_UPLOAD_ERROR')\n    expect(errors).toHaveLength(1)\n    expect(errors[0]).toMatchObject({\n      fileId: expect.stringMatching(/^__pending_.+_0_photos__$/),\n      status: 'failed'\n    })\n    expect(actions.some(a => a.type === 'RESOLVE_FOLDER_ITEMS')).toBe(false)\n  })\n\n  it('marks placeholders as unreadable if flatten throws NotFoundError', async () => {\n    const directoryEntry = createMockDirEntry('photos', [\n      createMockFileEntry('img.jpg')\n    ])\n    createDirectorySpy.mockReset()\n    const notFound = new Error('vanished')\n    notFound.name = 'NotFoundError'\n    createDirectorySpy.mockRejectedValue(notFound)\n    const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]\n\n    const actions = await runThunk(\n      addToUploadQueue(\n        entries,\n        'root',\n        {},\n        () => null,\n        () => null,\n        { client: fakeClient },\n        null,\n        () => null\n      )\n    )\n\n    const errors = actions.filter(a => a.type === 'RECEIVE_UPLOAD_ERROR')\n    expect(errors[0]).toMatchObject({\n      fileId: expect.stringMatching(/^__pending_.+_0_photos__$/),\n      status: 'unreadable'\n    })\n  })\n\n  it('fails placeholders and invokes onLimitExceeded when limit hit', async () => {\n    const directoryEntry = createMockDirEntry('photos', [\n      createMockFileEntry('a.jpg'),\n      createMockFileEntry('b.jpg'),\n      createMockFileEntry('c.jpg')\n    ])\n    const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]\n    const onLimitExceeded = jest.fn()\n\n    const actions = await runThunk(\n      addToUploadQueue(\n        entries,\n        'root',\n        {},\n        () => null,\n        () => null,\n        { client: fakeClient, maxFileCount: 2, onLimitExceeded },\n        null,\n        () => null\n      )\n    )\n\n    expect(onLimitExceeded).toHaveBeenCalledTimes(1)\n    const errors = actions.filter(a => a.type === 'RECEIVE_UPLOAD_ERROR')\n    expect(errors).toHaveLength(1)\n    expect(errors[0]).toMatchObject({\n      fileId: expect.stringMatching(/^__pending_.+_0_photos__$/),\n      status: 'failed'\n    })\n    // No flatten happened: no folders should have been created\n    expect(createDirectorySpy).not.toHaveBeenCalled()\n    expect(actions.some(a => a.type === 'RESOLVE_FOLDER_ITEMS')).toBe(false)\n  })\n\n  it('does not invoke onLimitExceeded when count is under the limit', async () => {\n    const directoryEntry = createMockDirEntry('photos', [\n      createMockFileEntry('a.jpg')\n    ])\n    const entries = [{ file: null, isDirectory: true, entry: directoryEntry }]\n    const onLimitExceeded = jest.fn()\n\n    const actions = await runThunk(\n      addToUploadQueue(\n        entries,\n        'root',\n        {},\n        () => null,\n        () => null,\n        { client: fakeClient, maxFileCount: 100, onLimitExceeded },\n        null,\n        () => null\n      )\n    )\n\n    expect(onLimitExceeded).not.toHaveBeenCalled()\n    // Make sure the under-limit path actually proceeded with flatten\n    // and didn't silently no-op.\n    expect(createDirectorySpy).toHaveBeenCalled()\n    expect(actions.some(a => a.type === 'RESOLVE_FOLDER_ITEMS')).toBe(true)\n  })\n})\n\ndescribe('onQueueEmpty', () => {\n  it('does not fire the callback while resolving placeholders are present', () => {\n    const callback = jest.fn()\n    const dispatch = jest.fn()\n    const getState = () => ({\n      upload: {\n        queue: [{ fileId: '__pending_0_photos__', status: 'resolving' }]\n      }\n    })\n    onQueueEmpty(callback)(dispatch, getState)\n    expect(callback).not.toHaveBeenCalled()\n  })\n\n  it('fires the callback when no resolving placeholders remain', () => {\n    const callback = jest.fn()\n    const dispatch = jest.fn()\n    const getState = () => ({\n      upload: {\n        queue: [\n          { fileId: 'a.txt', status: 'created', uploadedItem: { _id: 'a' } }\n        ]\n      }\n    })\n    onQueueEmpty(callback)(dispatch, getState)\n    expect(callback).toHaveBeenCalledWith(\n      expect.objectContaining({\n        createdItems: [{ _id: 'a' }]\n      })\n    )\n  })\n})\n"
  },
  {
    "path": "src/modules/viewer/CallToAction.jsx",
    "content": "import localforage from 'localforage'\nimport React, { Component } from 'react'\n\nimport { withClient } from 'cozy-client'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport CrossIcon from 'cozy-ui/transpiled/react/Icons/Cross'\n\nimport styles from './styles.styl'\n\nimport {\n  getDesktopAppDownloadLink,\n  isClientAlreadyInstalled,\n  NOVIEWER_DESKTOP_CTA\n} from '@/components/pushClient'\nimport Config from '@/config/config.json'\n\nclass CallToAction extends Component {\n  state = {\n    mustShow: false\n  }\n\n  async componentDidMount() {\n    if (Config.promoteDesktop.isActivated !== true) return\n    const seen = (await localforage.getItem(NOVIEWER_DESKTOP_CTA)) || false\n    if (!seen) {\n      try {\n        const mustSee = !(await isClientAlreadyInstalled(this.props.client))\n        if (mustSee) {\n          this.setState({ mustShow: true })\n        }\n      } catch (_e) {\n        this.setState({ mustShow: false })\n      }\n    }\n  }\n\n  markAsSeen = () => {\n    localforage.setItem(NOVIEWER_DESKTOP_CTA, true)\n    this.setState({ mustShow: false })\n  }\n\n  render() {\n    if (!this.state.mustShow || Config.promoteDesktop.isActivated !== true)\n      return null\n\n    const { t } = this.props\n\n    const link = getDesktopAppDownloadLink({ t })\n\n    return (\n      <div className={styles['pho-viewer-noviewer-cta']}>\n        <Icon\n          className={styles['pho-viewer-noviewer-cta-cross']}\n          color=\"var(--white)\"\n          icon={CrossIcon}\n          onClick={this.markAsSeen}\n        />\n        <h3>{t('Viewer.noviewer.cta.saveTime')}</h3>\n        <ul>\n          <li>\n            <a target=\"_blank\" href={link} rel=\"noreferrer\">\n              {t('Viewer.noviewer.cta.installDesktop')}\n            </a>\n          </li>\n          <li>{t('Viewer.noviewer.cta.accessFiles')}</li>\n        </ul>\n      </div>\n    )\n  }\n}\nexport default withClient(CallToAction)\n"
  },
  {
    "path": "src/modules/viewer/CallToAction.spec.jsx",
    "content": "import { render, waitFor } from '@testing-library/react'\nimport localforage from 'localforage'\nimport React from 'react'\n\nimport CallToAction from './CallToAction'\n\nimport { NOVIEWER_DESKTOP_CTA } from '@/components/pushClient'\n\njest.mock('localforage')\njest.mock('config/config.json', () => ({\n  promoteDesktop: { isActivated: true }\n}))\njest.mock('components/pushClient', () => ({\n  getDesktopAppDownloadLink: jest.fn().mockReturnValue('https://twake.app'),\n  isClientAlreadyInstalled: jest.fn().mockResolvedValueOnce(false),\n  isLinux: jest.fn(),\n  NOVIEWER_DESKTOP_CTA: 'noviewer_desktop_cta'\n}))\n\ndescribe('CallToAction', () => {\n  it('should get item noviewer desktop from localforage', async () => {\n    // Given\n    localforage.getItem = jest.fn().mockResolvedValueOnce(false)\n\n    // When\n    await waitFor(async () => {\n      render(<CallToAction t={jest.fn()} />)\n    })\n\n    // Then\n    expect(localforage.getItem).toHaveBeenCalledWith(NOVIEWER_DESKTOP_CTA)\n  })\n\n  it('should use rel=\"noreferrer\" (which implies rel=\"noopener\", because it is a security risk', async () => {\n    // Given\n    localforage.getItem = jest.fn().mockResolvedValueOnce(false)\n\n    // When\n    let container\n    await waitFor(async () => {\n      const result = render(<CallToAction t={jest.fn()} />)\n      container = result.container\n    })\n\n    // Then\n    expect(container.querySelector('a[target=\"_blank\"]')).toHaveAttribute(\n      'rel',\n      'noreferrer'\n    )\n  })\n})\n"
  },
  {
    "path": "src/modules/viewer/Fallback.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport CallToAction from './CallToAction'\nimport NoViewerButton from './NoViewerButton'\n\nconst Fallback = ({ file, t }) => {\n  return (\n    <>\n      <NoViewerButton file={file} t={t} />\n      <CallToAction t={t} />\n    </>\n  )\n}\n\nFallback.propTypes = {\n  file: PropTypes.object.isRequired,\n  t: PropTypes.func.isRequired // t is a prop passed by the parent and must not be received from the translate() HOC — otherwise the translation context becomes the one of the viewer instad of the app. See https://github.com/cozy/cozy-ui/issues/914#issuecomment-487959521\n}\n\nexport default Fallback\n"
  },
  {
    "path": "src/modules/viewer/FileOpenerExternal.jsx",
    "content": "/**\n * This component was previously named FileOpener\n * It has been renamed since it is used in :\n *  - an intent handler (aka service)\n *  - via cozydrive://\n */\n\nimport React, { useCallback, useEffect, useState } from 'react'\nimport { RemoveScroll } from 'react-remove-scroll'\nimport { useNavigate, useParams } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport Viewer, {\n  FooterActionButtons,\n  ForwardOrDownloadButton,\n  ToolbarButtons,\n  SharingButton\n} from 'cozy-viewer'\nimport { translate, useI18n } from 'twake-i18n'\n\nimport { ensureFileHasPath } from '@/components/FilesRealTimeQueries'\nimport Fallback from '@/modules/viewer/Fallback'\nimport {\n  isOfficeEnabled,\n  makeOnlyOfficeFileRoute\n} from '@/modules/views/OnlyOffice/helpers'\nimport { buildFileOrFolderByIdQuery } from '@/queries'\n\nconst FileNotFoundError = translate()(({ t }) => (\n  <pre className=\"u-error\">{t('FileOpenerExternal.fileNotFoundError')}</pre>\n))\n\nconst FileOpener = props => {\n  const navigate = useNavigate()\n  const { isDesktop } = useBreakpoints()\n  const { t } = useI18n()\n  const { fileId } = useParams()\n  const { showAlert } = useAlert()\n\n  const client = useClient()\n  const [state, setState] = useState({\n    loading: true,\n    file: null\n  })\n\n  const { service } = props\n  const { file, loading, fileNotFound } = state\n\n  const loadFileInfo = useCallback(\n    async id => {\n      try {\n        setState({ fileNotFound: false, loading: true })\n        const query = buildFileOrFolderByIdQuery(id)\n        const result = await client.query(query.definition(), query.options)\n\n        const file = await ensureFileHasPath(result.data, client)\n\n        setState({ file, loading: false })\n      } catch (_e) {\n        setState({ fileNotFound: true, loading: false })\n        showAlert({\n          message: t('alert.could_not_open_file')\n        })\n      }\n    },\n    [client, showAlert, t]\n  )\n\n  useEffect(() => {\n    const requestedFileId = fileId ?? props.fileId\n    if (requestedFileId) {\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      loadFileInfo(requestedFileId)\n    }\n  }, [fileId, props.fileId, loadFileInfo])\n\n  return (\n    <div className=\"u-pos-absolute u-w-100 u-h-100 u-bg-charcoalGrey\">\n      {loading && <Spinner size=\"xxlarge\" middle noMargin color=\"white\" />}\n      {fileNotFound && <FileNotFoundError />}\n      {!loading && !fileNotFound && (\n        <RemoveScroll>\n          <Viewer\n            files={[file]}\n            currentIndex={0}\n            onChangeRequest={() => {}}\n            onCloseRequest={service ? () => service.terminate() : null}\n            renderFallbackExtraContent={file => <Fallback file={file} t={t} />}\n            componentsProps={{\n              OnlyOfficeViewer: {\n                isEnabled: isOfficeEnabled(isDesktop),\n                opener: file => navigate(makeOnlyOfficeFileRoute(file.id))\n              }\n            }}\n          >\n            <ToolbarButtons>\n              <SharingButton variant=\"iconButton\" />\n            </ToolbarButtons>\n            <FooterActionButtons>\n              <SharingButton />\n              <ForwardOrDownloadButton variant=\"buttonIcon\" />\n            </FooterActionButtons>\n          </Viewer>\n        </RemoveScroll>\n      )}\n    </div>\n  )\n}\n\nexport default FileOpener\n"
  },
  {
    "path": "src/modules/viewer/FilesViewer.jsx",
    "content": "import React, { useCallback, useEffect, useState, useMemo } from 'react'\nimport { RemoveScroll } from 'react-remove-scroll'\nimport { useNavigate, useParams } from 'react-router-dom'\n\nimport { Q, useClient } from 'cozy-client'\nimport flag from 'cozy-flags'\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport Viewer, {\n  FooterActionButtons,\n  ForwardOrDownloadButton,\n  ToolbarButtons,\n  SharingButton\n} from 'cozy-viewer'\nimport { useI18n } from 'twake-i18n'\n\nimport { ensureFileHasPath } from '@/components/FilesRealTimeQueries'\nimport { FilesViewerLoading } from '@/components/FilesViewerLoading'\nimport RightClickFileMenu from '@/components/RightClick/RightClickFileMenu'\nimport { useCurrentFileId } from '@/hooks'\nimport { useMoreMenuActions } from '@/hooks/useMoreMenuActions'\nimport logger from '@/lib/logger'\nimport { navigateToModal } from '@/modules/actions/helpers'\nimport Fallback from '@/modules/viewer/Fallback'\nimport MoreMenu from '@/modules/viewer/MoreMenu'\nimport {\n  isOfficeEnabled,\n  makeOnlyOfficeFileRoute\n} from '@/modules/views/OnlyOffice/helpers'\n\n/**\n * Shows a set of files through cozy-ui's Viewer\n *\n * - Re-uses the cozy-client's Query for the current directory files\n *   with the same sort order.\n * - If the file to show is not present in the query results, will call\n *   fetchMore() on the query\n */\nconst FilesViewer = ({ filesQuery, files, onClose, onChange, viewerProps }) => {\n  const [currentFile, setCurrentFile] = useState(null)\n  const [fetchingMore, setFetchingMore] = useState(false)\n  const { isDesktop } = useBreakpoints()\n  const fileId = useCurrentFileId()\n  const client = useClient()\n  const { t } = useI18n()\n  const navigate = useNavigate()\n  const { driveId } = useParams()\n\n  const handleOnClose = useCallback(() => {\n    if (onClose) {\n      onClose()\n    }\n  }, [onClose])\n\n  const handleOnChange = useCallback(\n    nextFile => {\n      if (onChange) {\n        onChange(nextFile.id)\n      }\n    },\n    [onChange]\n  )\n\n  const currentIndex = useMemo(() => {\n    return files.findIndex(f => f.id === fileId)\n  }, [files, fileId])\n  const hasCurrentIndex = useMemo(() => currentIndex != -1, [currentIndex])\n  const viewerFiles = useMemo(\n    () => (hasCurrentIndex ? files : [currentFile]),\n    [hasCurrentIndex, files, currentFile]\n  )\n\n  useEffect(() => {\n    let isMounted = true\n\n    // If we can't find the file in the loaded files, that's probably because the user\n    // is trying to open a direct link to a file that wasn't in the first 50 files of\n    // the containing folder (it comes from a fetchMore...) ; we load the file attributes\n    // directly as a contingency measure\n    const fetchFileIfNecessary = async () => {\n      if (hasCurrentIndex) return\n      if (currentFile && isMounted) {\n        setCurrentFile(null)\n      }\n\n      try {\n        const { data } = await client.query(\n          Q('io.cozy.files').getById(fileId).sharingById(driveId)\n        )\n        const fileWithPath = await ensureFileHasPath(data, client)\n        isMounted && setCurrentFile(fileWithPath)\n      } catch (_e) {\n        logger.warn(\"can't find the file\")\n        handleOnClose()\n      }\n    }\n\n    fetchFileIfNecessary()\n\n    return () => {\n      isMounted = false\n    }\n  }, []) // eslint-disable-line react-hooks/exhaustive-deps\n\n  useEffect(() => {\n    let isMounted = true\n\n    // If we get close of the last file fetched, but we know there are more in the folder\n    // (it shouldn't happen in /recent), we fetch more files\n    const fetchMoreIfNecessary = async () => {\n      if (fetchingMore) {\n        return\n      }\n\n      setFetchingMore(true)\n      try {\n        const currentIndex = files.findIndex(f => f.id === fileId)\n\n        if (\n          (filesQuery.data.length - currentIndex <= 5 || currentIndex === -1) &&\n          filesQuery.hasMore &&\n          isMounted\n        ) {\n          await filesQuery.fetchMore()\n        }\n      } finally {\n        setFetchingMore(false)\n      }\n    }\n\n    fetchMoreIfNecessary()\n\n    return () => {\n      isMounted = false\n    }\n  }, [fetchingMore, filesQuery, files, fileId])\n\n  const viewerIndex = useMemo(\n    () => (hasCurrentIndex ? currentIndex : 0),\n    [hasCurrentIndex, currentIndex]\n  )\n\n  const actions = useMoreMenuActions(currentFile ?? {})\n\n  // If we can't find the file, we fallback to the (potentially loading)\n  // direct stat made by the viewer\n  if (currentIndex === -1 && !currentFile) {\n    return <FilesViewerLoading />\n  }\n\n  const redirectToPaywall = () => {\n    navigate('v/ai/paywall', { replace: true })\n  }\n\n  return (\n    <RightClickFileMenu\n      doc={viewerFiles[viewerIndex]}\n      actions={actions}\n      disabled={!viewerFiles[viewerIndex]}\n      prefixMenuId=\"FileViewerMenu\"\n    >\n      <RemoveScroll>\n        <Viewer\n          files={viewerFiles}\n          currentIndex={viewerIndex}\n          onChangeRequest={handleOnChange}\n          onCloseRequest={handleOnClose}\n          renderFallbackExtraContent={file => <Fallback file={file} t={t} />}\n          componentsProps={{\n            OnlyOfficeViewer: {\n              isEnabled: isOfficeEnabled(isDesktop),\n              opener: file => navigate(makeOnlyOfficeFileRoute(file.id))\n            },\n            toolbarProps: {\n              showFilePath: true,\n              onPaywallRedirect: redirectToPaywall\n            },\n            ...(viewerProps || {})\n          }}\n        >\n          <ToolbarButtons>\n            <MoreMenu file={viewerFiles[viewerIndex]} />\n\n            {flag('drive.new-file-viewer-ui.enabled') && (\n              <Button\n                variant=\"secondary\"\n                aria-label={t('Viewer.share_btn')}\n                label={t('Viewer.share_btn')}\n                startIcon={<Icon icon={ShareIcon} />}\n                onClick={() =>\n                  navigateToModal({\n                    navigate,\n                    pathname: '',\n                    files,\n                    path: 'share'\n                  })\n                }\n              />\n            )}\n          </ToolbarButtons>\n          <FooterActionButtons>\n            <SharingButton />\n            <ForwardOrDownloadButton variant=\"buttonIcon\" />\n          </FooterActionButtons>\n        </Viewer>\n      </RemoveScroll>\n    </RightClickFileMenu>\n  )\n}\n\nexport default React.memo(FilesViewer)\n"
  },
  {
    "path": "src/modules/viewer/FilesViewer.spec.jsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport React from 'react'\n\nimport CozyClient, { useQuery } from 'cozy-client'\n\nimport FilesViewer from './FilesViewer'\nimport AppLike from 'test/components/AppLike'\nimport { generateFile } from 'test/generate'\n\nimport { useCurrentFileId } from '@/hooks'\n\njest.mock('cozy-client/dist/hooks/useQuery', () => jest.fn())\njest.mock('cozy-keys-lib', () => ({\n  useVaultClient: jest.fn()\n}))\n\njest.mock('lib/logger', () => ({\n  error: jest.fn()\n}))\n\njest.mock('hooks')\n\njest.mock('@/components/FilesRealTimeQueries', () => ({\n  ...jest.requireActual('@/components/FilesRealTimeQueries'),\n  ensureFileHasPath: jest.fn().mockImplementation(file => Promise.resolve(file))\n}))\n\njest.mock('cozy-viewer', () => ({\n  ...jest.requireActual('cozy-viewer'),\n  __esModule: true,\n  default: () => <div>Viewer</div>\n}))\n\nconst sleep = duration => new Promise(resolve => setTimeout(resolve, duration))\n\ndescribe('FilesViewer', () => {\n  beforeEach(() => {\n    jest.clearAllMocks()\n  })\n\n  const setup = ({\n    fileId = 'file-foobar0',\n    nbFiles = 3,\n    totalCount,\n    client = new CozyClient({}),\n    useQueryResultAttributes\n  } = {}) => {\n    const filesFixture = Array(nbFiles)\n      .fill(null)\n      .map((x, i) => generateFile({ i, type: 'file' }))\n    const mockedUseQueryReturnedValues = {\n      data: filesFixture,\n      count: totalCount || filesFixture.length,\n      fetchMore: jest.fn().mockImplementation(() => {\n        throw new Error('Fetch more should not be called')\n      }),\n      ...useQueryResultAttributes\n    }\n    useQuery.mockReturnValue(mockedUseQueryReturnedValues)\n\n    useCurrentFileId.mockReturnValue(fileId)\n\n    return render(\n      <AppLike client={client}>\n        <FilesViewer\n          files={filesFixture}\n          filesQuery={mockedUseQueryReturnedValues}\n        />\n      </AppLike>\n    )\n  }\n\n  it('should render a Viewer', async () => {\n    setup()\n\n    const viewer = await screen.findByText('Viewer')\n    expect(viewer).toBeInTheDocument()\n  })\n\n  it('should fetch the file if necessary', async () => {\n    const client = new CozyClient({})\n    client.query = jest.fn().mockResolvedValue({\n      data: generateFile({ i: '51' })\n    })\n\n    setup({\n      client,\n      nbFiles: 50,\n      totalCount: 100,\n      fileId: 'file-foobar51'\n    })\n\n    const viewer = await screen.findByText('Viewer')\n    expect(viewer).toBeInTheDocument()\n    expect(client.query).toHaveBeenCalledWith(\n      expect.objectContaining({\n        id: 'file-foobar51',\n        doctype: 'io.cozy.files'\n      })\n    )\n  })\n\n  it('should call ensureFileHasPath when fetching file', async () => {\n    const client = new CozyClient({})\n    const fileData = generateFile({ i: '51' })\n    const fileWithPath = { ...fileData, path: '/test/path' }\n\n    client.query = jest.fn().mockResolvedValue({\n      data: fileData\n    })\n\n    const { ensureFileHasPath } = require('@/components/FilesRealTimeQueries')\n    ensureFileHasPath.mockResolvedValue(fileWithPath)\n\n    setup({\n      client,\n      nbFiles: 50,\n      totalCount: 100,\n      fileId: 'file-foobar51'\n    })\n\n    const viewer = await screen.findByText('Viewer')\n    expect(viewer).toBeInTheDocument()\n    expect(ensureFileHasPath).toHaveBeenCalledWith(fileData, client)\n    expect(client.query).toHaveBeenCalledWith(\n      expect.objectContaining({\n        id: 'file-foobar51',\n        doctype: 'io.cozy.files'\n      })\n    )\n  })\n\n  it('should fetch more files if necessary', async () => {\n    const client = new CozyClient({})\n    client.query = jest.fn().mockResolvedValue({\n      data: generateFile({ i: '51' })\n    })\n    const fetchMore = jest.fn().mockImplementation(async () => {\n      await sleep(50)\n    })\n\n    const hasMore = jest.fn().mockReturnValue(true)\n\n    setup({\n      client,\n      nbFiles: 50,\n      totalCount: 100,\n      fileId: 'file-foobar48',\n      useQueryResultAttributes: {\n        fetchMore,\n        hasMore\n      }\n    })\n\n    const viewer = await screen.findByText('Viewer')\n    expect(viewer).toBeInTheDocument()\n    expect(fetchMore).toHaveBeenCalledTimes(1)\n  })\n})\n"
  },
  {
    "path": "src/modules/viewer/MoreMenu.jsx",
    "content": "import cx from 'classnames'\nimport React, { useState, useRef } from 'react'\n\nimport ActionsMenu from 'cozy-ui/transpiled/react/ActionsMenu'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport DotsIcon from 'cozy-ui/transpiled/react/Icons/Dots'\nimport { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { useMoreMenuActions } from '@/hooks/useMoreMenuActions'\n\nconst MoreMenu = ({ file }) => {\n  const [showMenu, setShowMenu] = useState(false)\n  const { isDesktop } = useBreakpoints()\n  const anchorRef = useRef()\n  const actions = useMoreMenuActions(file)\n\n  if (file.trashed) return null\n\n  return (\n    <>\n      <IconButton\n        ref={anchorRef}\n        variant=\"secondary\"\n        className={cx({ 'u-white': isDesktop })}\n        onClick={() => setShowMenu(v => !v)}\n      >\n        <Icon icon={DotsIcon} />\n      </IconButton>\n      {showMenu && (\n        <ActionsMenu\n          open\n          ref={anchorRef}\n          docs={[file]}\n          actions={actions}\n          anchorOrigin={{\n            vertical: 'bottom',\n            horizontal: 'right'\n          }}\n          autoClose\n          onClose={() => setShowMenu(false)}\n        />\n      )}\n    </>\n  )\n}\n\nexport default MoreMenu\n"
  },
  {
    "path": "src/modules/viewer/NoViewerButton.jsx",
    "content": "import React from 'react'\n\nimport { useClient } from 'cozy-client'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\n\nimport { downloadFile } from './helpers'\n\nconst NoViewerButton = ({ file, t }) => {\n  const client = useClient()\n  return (\n    <Buttons\n      onClick={() => downloadFile(client, file)}\n      label={t('Viewer.noviewer.download')}\n    />\n  )\n}\n\nexport default NoViewerButton\n"
  },
  {
    "path": "src/modules/viewer/barviewer.styl",
    "content": "@require 'settings/z-index.styl'\n@require '../../styles/coz-bar-size.styl'\n\n.viewer-wrapper-with-bar\n    display flex\n    flex-direction column\n    position absolute\n    width 100%\n    height 'calc(100% - %s)' % $coz-bar-size\n    z-index ($bar-index - 2)\n"
  },
  {
    "path": "src/modules/viewer/helpers.js",
    "content": "export const downloadFile = async (client, file) => {\n  return client\n    .collection('io.cozy.files', { driveId: file.driveId })\n    .download(file)\n}\n"
  },
  {
    "path": "src/modules/viewer/styles.styl",
    "content": "@require 'settings/breakpoints.styl'\n\n.pho-viewer-noviewer-cta\n    position         relative\n    border-radius    .5rem\n    background-color rgba(255, 255, 255, .05)\n    padding          1rem\n    width            80%\n    max-width        36rem\n    margin-top       2rem\n\n    +medium-screen()\n        display none\n\n    .pho-viewer-noviewer-cta-cross\n        position absolute\n        top      1rem\n        right    1rem\n        cursor   pointer\n\n    h3\n        margin 0 0 1.1rem\n\n    h3:before\n        content      ''\n        position     relative\n        top          .2rem\n        width        1.5rem\n        height       1.5rem\n        margin-right .75rem\n        display      inline-block\n        background   embedurl('./icons/icon-magic-trick.svg') center center / cover no-repeat\n\n    ul\n        padding 0\n\n    li\n        list-style-type none\n        margin-bottom   1rem\n\n        &:last-child\n            margin-bottom 0\n\n        &:before\n            content       ''\n            position      relative\n            top           .3rem\n            border-radius 1rem\n            width         1.5rem\n            height        1.5rem\n            margin-right  .75rem\n            display       inline-block\n            background    var(--primaryColor) embedurl('./icons/icon-check.svg') center center no-repeat\n\n        a, a:hover, a:focus, a:visited\n            color var(--white)\n"
  },
  {
    "path": "src/modules/views/AI/AIAssistantPaywallView.tsx",
    "content": "import React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { AiAssistantPaywall } from 'cozy-ui-plus/dist/Paywall'\n\nconst AIAssistantPaywallView = (): JSX.Element => {\n  const navigate = useNavigate()\n\n  const onClose = (): void => {\n    navigate('..')\n  }\n\n  return <AiAssistantPaywall onClose={onClose} />\n}\n\nexport default AIAssistantPaywallView\n"
  },
  {
    "path": "src/modules/views/Drive/DriveFolderView.jsx",
    "content": "import React, { useContext, useEffect, useMemo } from 'react'\nimport { useDispatch } from 'react-redux'\nimport { useNavigate, Outlet, useLocation, useParams } from 'react-router-dom'\n\nimport { useQuery, useClient } from 'cozy-client'\nimport flag from 'cozy-flags'\nimport { useVaultClient } from 'cozy-keys-lib'\nimport {\n  useSharingContext,\n  useNativeFileSharing,\n  shareNative\n} from 'cozy-sharing'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport HarvestBanner from './HarvestBanner'\n\nimport useHead from '@/components/useHead'\nimport { DEFAULT_SORT } from '@/config/sort'\nimport { ROOT_DIR_ID } from '@/constants/config'\nimport { useClipboardContext } from '@/contexts/ClipboardProvider'\nimport { useCurrentFolderId, useDisplayedFolder, useFolderSort } from '@/hooks'\nimport { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'\nimport { FabContext } from '@/lib/FabProvider'\nimport { useModalContext } from '@/lib/ModalContext'\nimport { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext'\nimport {\n  share,\n  download,\n  trash,\n  rename,\n  infos,\n  versions,\n  hr,\n  selectAllItems,\n  summariseByAI\n} from '@/modules/actions'\nimport { addToFavorites } from '@/modules/actions/components/addToFavorites'\nimport { duplicateTo } from '@/modules/actions/components/duplicateTo'\nimport { moveTo } from '@/modules/actions/components/moveTo'\nimport { personalizeFolder } from '@/modules/actions/components/personalizeFolder'\nimport { removeFromFavorites } from '@/modules/actions/components/removeFromFavorites'\nimport { makeExtraColumnsNamesFromMedia } from '@/modules/certifications'\nimport { useExtraColumns } from '@/modules/certifications/useExtraColumns'\nimport AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'\nimport FabWithAddMenuContext from '@/modules/drive/FabWithAddMenuContext'\nimport Toolbar from '@/modules/drive/Toolbar'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport Dropzone from '@/modules/upload/Dropzone'\nimport DropzoneDnD from '@/modules/upload/DropzoneDnD'\nimport { useTrashRedirect } from '@/modules/views/Drive/useTrashRedirect'\nimport FolderView from '@/modules/views/Folder/FolderView'\nimport FolderViewBody from '@/modules/views/Folder/FolderViewBody'\nimport FolderViewBreadcrumb from '@/modules/views/Folder/FolderViewBreadcrumb'\nimport FolderViewHeader from '@/modules/views/Folder/FolderViewHeader'\nimport FolderViewBodyVz from '@/modules/views/Folder/virtualized/FolderViewBody'\nimport { useResumeUploadFromFlagship } from '@/modules/views/Upload/useResumeFromFlagship'\nimport {\n  buildDriveQuery,\n  buildFileWithSpecificMetadataAttributeQuery\n} from '@/queries'\n\n// Those extra columns names must match a metadata attribute name, e.g. carbonCopy or electronicSafe\nconst desktopExtraColumnsNames = []\nconst mobileExtraColumnsNames = []\n\nconst DriveFolderView = () => {\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n  const params = useParams()\n  const currentFolderId = useCurrentFolderId()\n  useHead()\n  const { isSelectionBarVisible, toggleSelectAllItems, isSelectAll } =\n    useSelectionContext()\n  const { isMobile, isDesktop } = useBreakpoints()\n  const { t, lang } = useI18n()\n  const { isFabDisplayed, setIsFabDisplayed } = useContext(FabContext)\n  const { isBigThumbnail, toggleThumbnailSize } = useThumbnailSizeContext()\n  const sharingContext = useSharingContext()\n  const { allLoaded, hasWriteAccess, refresh, isOwner, byDocId } =\n    sharingContext\n  const { isNativeFileSharingAvailable, shareFilesNative } =\n    useNativeFileSharing()\n  const client = useClient()\n  const vaultClient = useVaultClient()\n  const { pushModal, popModal } = useModalContext()\n  const dispatch = useDispatch()\n  const extraColumnsNames = makeExtraColumnsNamesFromMedia({\n    isMobile,\n    desktopExtraColumnsNames,\n    mobileExtraColumnsNames\n  })\n  const { showAlert } = useAlert()\n  const { hasClipboardData } = useClipboardContext()\n\n  const extraColumns = useExtraColumns({\n    columnsNames: extraColumnsNames,\n    queryBuilder: buildFileWithSpecificMetadataAttributeQuery,\n    currentFolderId\n  })\n\n  const { displayedFolder: _displayedFolder, isNotFound } = useDisplayedFolder()\n\n  const displayedFolder = useMemo(() => _displayedFolder, [_displayedFolder])\n\n  useTrashRedirect(displayedFolder)\n\n  const [sortOrder, setSortOrder, isSettingsLoaded] =\n    useFolderSort(currentFolderId)\n\n  // Sort by size does not work for directory, so in case sorting by size we will change to default sorting\n  const folderQuery = buildDriveQuery({\n    currentFolderId,\n    type: 'directory',\n    sortAttribute:\n      sortOrder.attribute !== 'size'\n        ? sortOrder.attribute\n        : DEFAULT_SORT.attribute,\n    sortOrder:\n      sortOrder.attribute !== 'size' ? sortOrder.order : DEFAULT_SORT.order\n  })\n  const fileQuery = buildDriveQuery({\n    currentFolderId,\n    type: 'file',\n    sortAttribute: sortOrder.attribute,\n    sortOrder: sortOrder.order\n  })\n\n  const foldersResult = useQuery(folderQuery.definition, folderQuery.options)\n  const filesResult = useQuery(fileQuery.definition, fileQuery.options)\n\n  let allResults = [foldersResult, filesResult]\n\n  const isInError = allResults.some(result => result.fetchStatus === 'failed')\n  const isLoading = allResults.some(\n    result => result.fetchStatus === 'loading' && !result.lastUpdate\n  )\n  const isPending = allResults.some(result => result.fetchStatus === 'pending')\n\n  const canWriteToCurrentFolder = hasWriteAccess(currentFolderId)\n\n  useKeyboardShortcuts({\n    canPaste: hasClipboardData && canWriteToCurrentFolder,\n    client,\n    items: [...(foldersResult.data || []), ...(filesResult.data || [])],\n    sharingContext,\n    pushModal,\n    popModal,\n    refresh\n  })\n\n  const actionsOptions = {\n    client,\n    t,\n    lang,\n    vaultClient,\n    pushModal,\n    popModal,\n    refresh,\n    dispatch,\n    navigate,\n    pathname,\n    hasWriteAccess: canWriteToCurrentFolder,\n    canMove: true,\n    isPublic: false,\n    allLoaded,\n    showAlert,\n    isOwner,\n    byDocId,\n    isMobile,\n    isNativeFileSharingAvailable,\n    shareFilesNative,\n    selectAll: () =>\n      toggleSelectAllItems(allResults.map(query => query.data).flat()),\n    isSelectAll,\n    displayedFolder\n  }\n  const actions = makeActions(\n    [\n      selectAllItems,\n      share,\n      shareNative,\n      download,\n      hr,\n      summariseByAI,\n      hr,\n      rename,\n      moveTo,\n      duplicateTo,\n      addToFavorites,\n      removeFromFavorites,\n      personalizeFolder,\n      infos,\n      hr,\n      versions,\n      hr,\n      trash\n    ],\n    actionsOptions\n  )\n\n  const rootBreadcrumbPath = useMemo(\n    () => ({\n      id: ROOT_DIR_ID,\n      name: t('breadcrumb.title_drive')\n    }),\n    [t]\n  )\n\n  useResumeUploadFromFlagship()\n\n  useEffect(() => {\n    if (canWriteToCurrentFolder) {\n      setIsFabDisplayed(!isDesktop)\n      return () => {\n        // to not have this set to false on other views after using this view\n        setIsFabDisplayed(false)\n      }\n    }\n  }, [setIsFabDisplayed, isDesktop, canWriteToCurrentFolder])\n\n  const DropzoneComp =\n    flag('drive.virtualization.enabled') && !isMobile ? DropzoneDnD : Dropzone\n\n  return (\n    <FolderView isNotFound={isNotFound}>\n      <DropzoneComp\n        disabled={!canWriteToCurrentFolder}\n        displayedFolder={displayedFolder}\n      >\n        <FolderViewHeader>\n          {currentFolderId && (\n            <FolderViewBreadcrumb\n              rootBreadcrumbPath={rootBreadcrumbPath}\n              currentFolderId={currentFolderId}\n            />\n          )}\n          <Toolbar\n            canUpload={true}\n            canCreateFolder={true}\n            disabled={isLoading || isInError || isPending}\n            isBigThumbnail={isBigThumbnail}\n            toggleThumbnailSize={toggleThumbnailSize}\n          />\n        </FolderViewHeader>\n        {flag('drive.show.harvest-banner') && (\n          <HarvestBanner folderId={currentFolderId} />\n        )}\n        {flag('drive.virtualization.enabled') && !isMobile ? (\n          <FolderViewBodyVz\n            actions={actions}\n            queryResults={allResults}\n            currentFolderId={currentFolderId}\n            displayedFolder={displayedFolder}\n            canDrag\n            canUpload={canWriteToCurrentFolder}\n            orderProps={{\n              sortOrder,\n              setOrder: setSortOrder,\n              isSettingsLoaded\n            }}\n          />\n        ) : (\n          <FolderViewBody\n            actions={actions}\n            queryResults={allResults}\n            canSort\n            currentFolderId={currentFolderId}\n            displayedFolder={displayedFolder}\n            extraColumns={extraColumns}\n            canUpload={canWriteToCurrentFolder}\n            orderProps={{\n              sortOrder,\n              setOrder: setSortOrder,\n              isSettingsLoaded\n            }}\n          />\n        )}\n        {isFabDisplayed && (\n          <AddMenuProvider\n            componentsProps={{\n              AddMenu: {\n                anchorOrigin: {\n                  vertical: 'top',\n                  horizontal: 'left'\n                }\n              }\n            }}\n            canCreateFolder={true}\n            canUpload={true}\n            disabled={isLoading || isInError || isPending}\n            navigate={navigate}\n            params={params}\n            displayedFolder={displayedFolder}\n            isSelectionBarVisible={isSelectionBarVisible}\n          >\n            <FabWithAddMenuContext />\n          </AddMenuProvider>\n        )}\n        <Outlet />\n      </DropzoneComp>\n    </FolderView>\n  )\n}\n\nexport { DriveFolderView }\n"
  },
  {
    "path": "src/modules/views/Drive/DriveFolderView.spec.jsx",
    "content": "import { act, render } from '@testing-library/react'\nimport React from 'react'\n\nimport AppLike from 'test/components/AppLike'\nimport { setupStoreAndClient } from 'test/setup'\n\nimport AppRoute from '@/modules/navigation/AppRoute'\n\njest.mock('cozy-harvest-lib', () => ({\n  LaunchTriggerCard: jest.fn()\n}))\njest.mock('modules/views/Drive/useTrashRedirect', () => ({\n  useTrashRedirect: jest.fn()\n}))\n\njest.mock('../../upload/Dropzone', () => ({ children }) => (\n  <div>{children}</div>\n))\n\njest.mock(\n  '../Folder/FolderViewBreadcrumb',\n  () =>\n    ({ rootBreadcrumbPath, currentFolderId }) => (\n      <div\n        data-path={rootBreadcrumbPath}\n        data-folder-id={currentFolderId}\n        data-testid=\"FolderViewBreadcrumb\"\n      />\n    )\n)\n\njest.mock('hooks', () => ({\n  useCurrentFolderId: jest.fn().mockReturnValue('1234'),\n  useDisplayedFolder: jest.fn().mockReturnValue({ id: '5678' }),\n  useFolderSort: jest.fn(() => [{ attribute: 'name', order: 'asc' }, jest.fn()])\n}))\n\njest.mock('modules/shareddrives/hooks/useSharedDrives', () => ({\n  useSharedDrives: jest.fn().mockReturnValue([])\n}))\n\njest.mock('cozy-keys-lib', () => ({\n  useVaultClient: jest.fn()\n}))\n\njest.mock('components/useHead', () => jest.fn())\n\njest.mock('@/modules/shareddrives/hooks/useSharedDriveFolder', () => ({\n  useSharedDriveFolder: jest.fn().mockReturnValue({\n    sharedDriveQuery: {},\n    sharedDriveResult: { data: null }\n  })\n}))\n\ndescribe('Drive View', () => {\n  const setup = () => {\n    const { store, client } = setupStoreAndClient()\n    client.plugins.realtime = {\n      subscribe: jest.fn(),\n      unsubscribe: jest.fn()\n    }\n    client.query = jest.fn().mockReturnValue({ data: [] })\n    client.fetchQueryAndGetFromState = jest.fn().mockReturnValue({ data: [] })\n    const rendered = render(\n      <AppLike client={client} store={store}>\n        <AppRoute />\n      </AppLike>\n    )\n    return { ...rendered, client }\n  }\n\n  it('should use FolderViewBreadcrumb with correct rootBreadcrumbPath', async () => {\n    let render\n\n    await act(async () => {\n      render = await setup()\n    })\n\n    const { getByTestId } = render\n    expect(getByTestId('FolderViewBreadcrumb')).toBeTruthy()\n    expect(\n      getByTestId('FolderViewBreadcrumb').hasAttribute('data-path')\n    ).toEqual(true)\n    expect(\n      getByTestId('FolderViewBreadcrumb').getAttribute('data-folder-id')\n    ).toEqual('1234')\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Drive/FilesViewerDrive.jsx",
    "content": "import React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useQuery } from 'cozy-client'\n\nimport { FilesViewerLoading } from '@/components/FilesViewerLoading'\nimport { useCurrentFolderId, useFolderSort } from '@/hooks'\nimport { getFolderPath, getViewerPath } from '@/modules/routeUtils'\nimport FilesViewer from '@/modules/viewer/FilesViewer'\nimport { buildDriveQuery } from '@/queries'\n\nconst FilesViewerDrive = () => {\n  const navigate = useNavigate()\n  const [sortOrder] = useFolderSort()\n  const folderId = useCurrentFolderId()\n\n  const buildedFilesQuery = buildDriveQuery({\n    currentFolderId: folderId,\n    type: 'file',\n    sortAttribute: sortOrder.attribute,\n    sortOrder: sortOrder.order\n  })\n\n  const filesQuery = useQuery(\n    buildedFilesQuery.definition,\n    buildedFilesQuery.options\n  )\n\n  const viewableFiles = filesQuery.data\n\n  if (viewableFiles) {\n    return (\n      <FilesViewer\n        files={viewableFiles}\n        filesQuery={filesQuery}\n        onClose={() => navigate(getFolderPath(folderId))}\n        onChange={fileId => navigate(`${getViewerPath(folderId, fileId)}`)}\n      />\n    )\n  }\n\n  return <FilesViewerLoading />\n}\n\nexport default FilesViewerDrive\n"
  },
  {
    "path": "src/modules/views/Drive/HarvestBanner.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport { useQuery, isQueryLoading, Q } from 'cozy-client'\nimport { LaunchTriggerCard } from 'cozy-harvest-lib'\nimport Divider from 'cozy-ui/transpiled/react/Divider'\nimport { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport useDocument from '@/components/useDocument'\nimport { getKonnectorSlugFromFile } from '@/lib/konnectors'\nimport {\n  buildTriggersQueryByAccountId,\n  buildFileOrFolderByIdQuery\n} from '@/queries'\n\nconst HarvestBanner = ({ folderId }) => {\n  const folder = useDocument('io.cozy.files', folderId)\n  const { isMobile } = useBreakpoints()\n\n  let konnectorSlug = undefined\n  let accountId = undefined\n\n  const fileId = folder?.relationships?.contents?.data?.[0]?.id\n  const fileQuery = buildFileOrFolderByIdQuery(fileId)\n  const file = useQuery(fileQuery.definition, {\n    ...fileQuery.options,\n    enabled: Boolean(fileId)\n  })\n  if (file.data) {\n    konnectorSlug = getKonnectorSlugFromFile(file.data)\n    accountId = file.data.cozyMetadata?.sourceAccount\n  }\n  const queryTriggers = buildTriggersQueryByAccountId(accountId)\n  const { data: triggers, ...triggersQueryLeft } = useQuery(\n    queryTriggers.definition,\n    queryTriggers.options\n  )\n  const isTriggersLoading = isQueryLoading(triggersQueryLeft)\n  const konnector = useQuery(\n    Q('io.cozy.konnectors').getById(`io.cozy.konnectors/${konnectorSlug}`),\n    {\n      as: `io.cozy.konnectors/${konnectorSlug}`,\n      enabled: Boolean(konnectorSlug),\n      singleDocData: true\n    }\n  )\n\n  if (!konnector.data || konnector.data.length === 0 || isTriggersLoading) {\n    return null\n  }\n\n  return (\n    <div className=\"u-mh-0-s u-mb-0-s u-mh-2 u-mb-1\">\n      <LaunchTriggerCard\n        flowProps={{\n          initialTrigger: triggers[0],\n          konnector: konnector.data\n        }}\n        konnectorRoot={`harvest/${konnectorSlug}`}\n      />\n      {isMobile && (\n        <Divider\n          style={{\n            height: 12,\n            backgroundColor: 'var(--defaultBackgroundColor)'\n          }}\n        />\n      )}\n    </div>\n  )\n}\n\nHarvestBanner.propTypes = {\n  folderId: PropTypes.string.isRequired\n}\n\nexport default HarvestBanner\n"
  },
  {
    "path": "src/modules/views/Drive/KonnectorRoutes.jsx",
    "content": "import React from 'react'\nimport { useParams, useNavigate } from 'react-router-dom'\n\nimport { useQuery } from 'cozy-client'\nimport { HarvestRoutes } from 'cozy-harvest-lib'\nimport datacardOptions from 'cozy-harvest-lib/dist/datacards/datacardOptions'\n\nimport {\n  buildTriggersQueryByKonnectorSlug,\n  buildKonnectorsQueryById\n} from '@/queries'\n\nconst KonnectorRoutes = () => {\n  const { konnectorSlug } = useParams()\n  const navigate = useNavigate()\n\n  const queryTriggers = buildTriggersQueryByKonnectorSlug(konnectorSlug)\n  const { data: triggers } = useQuery(\n    queryTriggers.definition,\n    queryTriggers.options\n  )\n  const trigger = triggers?.[0]\n\n  const queryKonnector = buildKonnectorsQueryById({\n    id: `io.cozy.konnectors/${konnectorSlug}`,\n    enabled: Boolean(trigger)\n  })\n  const { data: konnectors } = useQuery(\n    queryKonnector.definition,\n    queryKonnector.options\n  )\n  const konnector = konnectors?.[0]\n\n  const konnectorWithTriggers = konnector\n    ? { ...konnector, triggers: { data: triggers } }\n    : undefined\n\n  const onDismiss = () => navigate('..')\n\n  return (\n    <HarvestRoutes\n      konnector={konnectorWithTriggers}\n      konnectorSlug={konnectorSlug}\n      datacardOptions={datacardOptions}\n      onSuccess={onDismiss}\n      onDismiss={onDismiss}\n      konnectorRoot={`harvest/${konnectorSlug}`}\n    />\n  )\n}\n\nexport { KonnectorRoutes }\n"
  },
  {
    "path": "src/modules/views/Drive/SharedDrivesFolderView.tsx",
    "content": "import React, { FC, useMemo } from 'react'\nimport { Outlet } from 'react-router-dom'\n\nimport { useQuery } from 'cozy-client'\nimport { Content } from 'cozy-ui/transpiled/react/Layout'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { ROOT_DIR_ID } from '@/constants/config'\nimport { useFolderSort } from '@/hooks'\nimport useDisplayedFolder from '@/hooks/useDisplayedFolder'\nimport { makeExtraColumnsNamesFromMedia } from '@/modules/certifications'\nimport {\n  useExtraColumns,\n  ExtraColumn\n} from '@/modules/certifications/useExtraColumns'\nimport { FolderBody } from '@/modules/folder/components/FolderBody'\nimport FolderView from '@/modules/views/Folder/FolderView'\nimport FolderViewBreadcrumb from '@/modules/views/Folder/FolderViewBreadcrumb'\nimport FolderViewHeader from '@/modules/views/Folder/FolderViewHeader'\nimport {\n  buildDriveQuery,\n  buildFileWithSpecificMetadataAttributeQuery\n} from '@/queries'\n\nconst desktopExtraColumnsNames = ['carbonCopy', 'electronicSafe']\nconst mobileExtraColumnsNames: string[] = []\n\nconst SharedDrivesFolderView: FC = () => {\n  const { isMobile } = useBreakpoints()\n  const { t } = useI18n()\n  const { isNotFound } = useDisplayedFolder()\n\n  const extraColumnsNames = makeExtraColumnsNamesFromMedia({\n    isMobile,\n    desktopExtraColumnsNames,\n    mobileExtraColumnsNames\n  })\n\n  const extraColumns = useExtraColumns({\n    columnsNames: extraColumnsNames,\n    queryBuilder: buildFileWithSpecificMetadataAttributeQuery,\n    currentFolderId: 'io.cozy.files.shared-drives-dir'\n  }) as ExtraColumn[]\n\n  const [sortOrder] = useFolderSort('io.cozy.files.shared-drives-dir')\n\n  const folderQuery = buildDriveQuery({\n    currentFolderId: 'io.cozy.files.shared-drives-dir',\n    type: 'directory',\n    sortAttribute: sortOrder.attribute,\n    sortOrder: sortOrder.order\n  })\n  const fileQuery = buildDriveQuery({\n    currentFolderId: 'io.cozy.files.shared-drives-dir',\n    type: 'file',\n    sortAttribute: sortOrder.attribute,\n    sortOrder: sortOrder.order\n  })\n\n  const foldersResult = useQuery(folderQuery.definition, folderQuery.options)\n  const filesResult = useQuery(fileQuery.definition, fileQuery.options)\n\n  const queryResults = [foldersResult, filesResult]\n\n  const rootBreadcrumbPath = useMemo(\n    () => ({\n      id: ROOT_DIR_ID,\n      name: t('breadcrumb.title_drive')\n    }),\n    [t]\n  )\n\n  return (\n    <FolderView isNotFound={isNotFound}>\n      <Content className={isMobile ? '' : 'u-pt-1'}>\n        <FolderViewHeader>\n          <FolderViewBreadcrumb\n            rootBreadcrumbPath={rootBreadcrumbPath}\n            currentFolderId=\"io.cozy.files.shared-drives-dir\"\n          />\n        </FolderViewHeader>\n        <FolderBody\n          folderId=\"io.cozy.files.shared-drives-dir\"\n          queryResults={queryResults}\n          extraColumns={extraColumns}\n        />\n        <Outlet />\n      </Content>\n    </FolderView>\n  )\n}\n\nexport { SharedDrivesFolderView }\n"
  },
  {
    "path": "src/modules/views/Drive/useTrashRedirect.jsx",
    "content": "import { useEffect } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { TRASH_DIR_PATH } from '@/constants/config'\nexport const useTrashRedirect = displayedFolder => {\n  const navigate = useNavigate()\n\n  useEffect(() => {\n    if (displayedFolder && displayedFolder.path.startsWith(TRASH_DIR_PATH)) {\n      navigate('/trash/' + displayedFolder.id)\n    }\n  }, [navigate, displayedFolder])\n}\n"
  },
  {
    "path": "src/modules/views/Favorites/FavoritesView.tsx",
    "content": "import React, { FC } from 'react'\nimport { useDispatch } from 'react-redux'\nimport { Outlet, useNavigate, useLocation } from 'react-router-dom'\n\nimport { useClient, useQuery } from 'cozy-client'\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport {\n  useSharingContext,\n  useNativeFileSharing,\n  shareNative\n} from 'cozy-sharing'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { Content } from 'cozy-ui/transpiled/react/Layout'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { useFolderSort } from '@/hooks'\nimport { useModalContext } from '@/lib/ModalContext'\nimport {\n  download,\n  rename,\n  infos,\n  versions,\n  share,\n  hr,\n  trash,\n  summariseByAI\n} from '@/modules/actions'\nimport { addToFavorites } from '@/modules/actions/components/addToFavorites'\nimport { moveTo } from '@/modules/actions/components/moveTo'\nimport { removeFromFavorites } from '@/modules/actions/components/removeFromFavorites'\nimport { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'\nimport { makeExtraColumnsNamesFromMedia } from '@/modules/certifications'\nimport {\n  useExtraColumns,\n  ExtraColumn\n} from '@/modules/certifications/useExtraColumns'\nimport AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'\nimport FabWithAddMenuContext from '@/modules/drive/FabWithAddMenuContext'\nimport Toolbar from '@/modules/drive/Toolbar'\nimport { FolderBody } from '@/modules/folder/components/FolderBody'\nimport { isNextcloudShortcut } from '@/modules/nextcloud/helpers'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport FolderView from '@/modules/views/Folder/FolderView'\nimport FolderViewHeader from '@/modules/views/Folder/FolderViewHeader'\nimport {\n  buildFavoritesQuery,\n  buildFileWithSpecificMetadataAttributeQuery\n} from '@/queries'\n\nconst desktopExtraColumnsNames = ['carbonCopy', 'electronicSafe']\nconst mobileExtraColumnsNames: string[] = []\n\nconst FavoritesView: FC = () => {\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n  const { isMobile } = useBreakpoints()\n  const { t, lang } = useI18n()\n  const client = useClient()\n  const { isSelectionBarVisible } = useSelectionContext()\n  const { pushModal, popModal } = useModalContext()\n  const { allLoaded, refresh } = useSharingContext()\n  const { isNativeFileSharingAvailable, shareFilesNative } =\n    useNativeFileSharing()\n  const dispatch = useDispatch()\n  const { showAlert } = useAlert()\n  const [sortOrder] = useFolderSort('favorites')\n\n  const extraColumnsNames = makeExtraColumnsNamesFromMedia({\n    isMobile,\n    desktopExtraColumnsNames,\n    mobileExtraColumnsNames\n  })\n\n  const extraColumns = useExtraColumns({\n    columnsNames: extraColumnsNames,\n    queryBuilder: buildFileWithSpecificMetadataAttributeQuery,\n    currentFolderId: 'io.cozy.files.shared-drives-dir'\n  }) as ExtraColumn[]\n\n  const favoritesQuery = buildFavoritesQuery({\n    sortAttribute: sortOrder.attribute,\n    sortOrder: sortOrder.order\n  })\n  const favoritesResult = useQuery(\n    favoritesQuery.definition,\n    favoritesQuery.options\n  ) as {\n    data?: IOCozyFile[] | null\n  }\n\n  const handleInteractWith = (file: IOCozyFile): boolean =>\n    !isNextcloudShortcut(file)\n\n  const actionsOptions = {\n    client,\n    t,\n    lang,\n    pushModal,\n    popModal,\n    refresh,\n    dispatch,\n    navigate,\n    pathname,\n    hasWriteAccess: true,\n    canMove: true,\n    isPublic: false,\n    allLoaded,\n    showAlert,\n    isMobile,\n    isNativeFileSharingAvailable,\n    shareFilesNative\n  }\n\n  const actions = makeActions(\n    [\n      share,\n      shareNative,\n      download,\n      hr,\n      summariseByAI,\n      hr,\n      rename,\n      moveTo,\n      addToFavorites,\n      removeFromFavorites,\n      infos,\n      hr,\n      versions,\n      hr,\n      trash\n    ],\n    actionsOptions\n  )\n  return (\n    <FolderView isNotFound={false}>\n      <Content className={isMobile ? '' : 'u-pt-1'}>\n        <FolderViewHeader>\n          <Breadcrumb path={[{ name: t('breadcrumb.title_favorites') }]} />\n          <Toolbar canUpload={false} canCreateFolder={false} />\n        </FolderViewHeader>\n        <FolderBody\n          folderId=\"io.cozy.files.shared-drives-dir\"\n          queryResults={[favoritesResult]}\n          extraColumns={extraColumns}\n          actions={actions}\n          canSort={true}\n          canInteractWith={handleInteractWith}\n        />\n        <Outlet />\n        {isMobile && (\n          <AddMenuProvider\n            canCreateFolder={true}\n            canUpload={true}\n            disabled={false}\n            displayedFolder={null}\n            isSelectionBarVisible={isSelectionBarVisible}\n            isPublic={false}\n            isReadOnly={false}\n            refreshFolderContent={(): void => {\n              // Empty function needed because this props is required\n            }}\n          >\n            <FabWithAddMenuContext noSidebar={false} />\n          </AddMenuProvider>\n        )}\n      </Content>\n    </FolderView>\n  )\n}\n\nexport { FavoritesView }\n"
  },
  {
    "path": "src/modules/views/Folder/ColoredFolder.jsx",
    "content": "import React, { useRef } from 'react'\n\nimport { shadeColor } from './helpers'\n\nlet gradientIdCounter = 0\n\nfunction ColoredFolder({ color = '#1D7AFF', ...props }) {\n  const gradientIdRef = useRef(null)\n  if (gradientIdRef.current === null) {\n    gradientIdRef.current = `file-type-colored-folder-gradient-${++gradientIdCounter}`\n  }\n  const gradientId = gradientIdRef.current\n  const base = color\n  const dark = shadeColor(base, { to: 'black', factor: 0.1 })\n  const lightStrong = shadeColor(base, { to: 'white', factor: 0.45 })\n  const light = shadeColor(base, { to: 'white', factor: 0.35 })\n  const mid = shadeColor(base, { to: 'white', factor: 0.15 })\n  return (\n    <svg viewBox=\"0 0 16 14\" fill=\"none\" {...props}>\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M8 2.125h6.4c.88 0 1.6.731 1.6 1.625v8.125c0 .894-.72 1.625-1.6 1.625H1.6c-.88 0-1.6-.731-1.6-1.625l.008-9.75C.008 1.231.72.5 1.6.5h4.8L8 2.125z\"\n        fill={`url(#${gradientId})`}\n      />\n      <defs>\n        <linearGradient\n          id={gradientId}\n          x1={8}\n          y1={0.5}\n          x2={8}\n          y2={16.25}\n          gradientUnits=\"userSpaceOnUse\"\n        >\n          <stop offset={0.044} stopColor={dark} />\n          <stop offset={0.13} stopColor={lightStrong} />\n          <stop offset={0.617} stopColor={light} />\n          <stop offset={1} stopColor={mid} />\n        </linearGradient>\n      </defs>\n    </svg>\n  )\n}\n\nexport default ColoredFolder\n"
  },
  {
    "path": "src/modules/views/Folder/CustomizedIcon.jsx",
    "content": "import React from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\n\nimport ColoredFolder from './ColoredFolder'\n\nimport { getIcon } from '@/components/IconPicker/IconIndex'\nimport IconStack from '@/components/IconStack'\n\nexport const CustomizedIcon = ({\n  selectedColor = '#46a2ff',\n  selectedIcon,\n  selectedIconColor,\n  size\n}) => {\n  return (\n    <div>\n      <IconStack\n        offset={{ vertical: '3%' }}\n        backgroundIcon={\n          <div\n            className=\"u-pos-relative u-dib\"\n            style={{\n              width: size,\n              height: size\n            }}\n          >\n            <ColoredFolder\n              color={selectedColor}\n              width={size || 32}\n              height={size || 32}\n            />\n          </div>\n        }\n        foregroundIcon={\n          <Icon\n            icon={getIcon(selectedIcon)}\n            color={selectedIconColor}\n            size={size / 2.5 || 16}\n          />\n        }\n      />\n    </div>\n  )\n}\n"
  },
  {
    "path": "src/modules/views/Folder/FolderCustomizer.jsx",
    "content": "import React, { useState, useRef } from 'react'\n\nimport { useClient, useQuery } from 'cozy-client'\nimport Backdrop from 'cozy-ui/transpiled/react/Backdrop'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport { FixedDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Grid from 'cozy-ui/transpiled/react/Grid'\nimport { Spinner } from 'cozy-ui/transpiled/react/Spinner'\nimport Tab from 'cozy-ui/transpiled/react/Tab'\nimport Tabs from 'cozy-ui/transpiled/react/Tabs'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { CustomizedIcon } from './CustomizedIcon'\n\nimport styles from '@/styles/folder-customizer.styl'\n\nimport { ColorPicker } from '@/components/ColorPicker/ColorPicker'\nimport { COLORS } from '@/components/ColorPicker/constants'\nimport { IconPicker } from '@/components/IconPicker/index.jsx'\nimport { addRecentIcon } from '@/hooks'\nimport logger from '@/lib/logger'\nimport {\n  buildFileOrFolderByIdQuery,\n  buildSharedDriveFileOrFolderByIdQuery\n} from '@/queries'\n\nexport const FolderCustomizerModal = ({ folderId, driveId, onClose }) => {\n  const folderQuery = driveId\n    ? buildSharedDriveFileOrFolderByIdQuery({ fileId: folderId, driveId })\n    : buildFileOrFolderByIdQuery(folderId)\n  const result = useQuery(folderQuery.definition, folderQuery.options)\n  const { fetchStatus, data: folder } = result\n\n  return fetchStatus !== 'loaded' ? (\n    <Backdrop isOver open>\n      <Spinner size=\"xxlarge\" middle noMargin color=\"var(--white)\" />\n    </Backdrop>\n  ) : (\n    <DumbFolderCustomizer folder={folder} driveId={driveId} onClose={onClose} />\n  )\n}\n\nFolderCustomizerModal.displayName = 'FolderCustomizerModal'\n\nconst DumbFolderCustomizer = ({ folder, driveId, onClose }) => {\n  const { t } = useI18n()\n  const tabItems = ['colors', 'icons']\n  const [selectedColor, setSelectedColor] = useState(\n    folder.metadata?.decorations?.color || COLORS[8]\n  )\n  const [selectedIcon, setSelectedIcon] = useState(\n    folder.metadata?.decorations?.icon || null\n  )\n  const [selectedIconColor, setSelectedIconColor] = useState(\n    folder.metadata?.decorations?.icon_color\n  )\n  const { showAlert } = useAlert()\n  const client = useClient()\n\n  const handleColorSelect = color => {\n    setSelectedColor(color)\n  }\n  const handleIconSelect = iconName => {\n    setSelectedIcon(iconName)\n  }\n  const handleIconColorSelect = color => {\n    setSelectedIconColor(color)\n  }\n\n  const handleApply = async () => {\n    try {\n      // Prepare decorations object\n      const decorations = {\n        ...folder.metadata?.decorations,\n        color: selectedColor\n      }\n\n      // Only add icon and icon_color if an actual icon is selected (not \"none\")\n      if (selectedIcon && selectedIcon !== 'none') {\n        decorations.icon = selectedIcon\n        if (selectedIconColor) {\n          decorations.icon_color = selectedIconColor\n        }\n      } else {\n        delete decorations.icon\n        delete decorations.icon_color\n      }\n\n      if (driveId) {\n        await client.collection('io.cozy.files', { driveId }).update({\n          ...folder,\n          metadata: {\n            ...folder.metadata,\n            decorations\n          }\n        })\n      } else {\n        await client.save({\n          ...folder,\n          metadata: {\n            ...folder.metadata,\n            decorations\n          }\n        })\n      }\n\n      if (selectedIcon && selectedIcon !== 'none') {\n        addRecentIcon(selectedIcon)\n      }\n    } catch (error) {\n      logger.error(`Error while updating folder decoration`, error)\n      showAlert({\n        message: t('FolderCustomizer.error'),\n        severity: 'error'\n      })\n    } finally {\n      onClose()\n    }\n  }\n  const [selectedTab, setSelectedTab] = useState(0)\n  const iconContainerRef = useRef(null)\n\n  const handleTabChange = (_, newValue) => {\n    setSelectedTab(newValue)\n  }\n\n  return (\n    <FixedDialog\n      size=\"small\"\n      onClose={onClose}\n      open={true}\n      title={t('FolderCustomizer.title')}\n      content={\n        <Grid\n          container\n          wrap=\"nowrap\"\n          direction=\"column\"\n          className={`u-h-100 ${styles['foldercustomizer-dialog']}`}\n        >\n          <Grid item className=\"u-mb-1 u-ta-center\">\n            <CustomizedIcon\n              selectedColor={selectedColor}\n              selectedIcon={selectedIcon}\n              selectedIconColor={selectedIconColor}\n              size={52}\n            />\n          </Grid>\n\n          <Grid container item justifyContent=\"center\" className=\"u-mb-1\">\n            <Tabs\n              narrowed\n              value={selectedTab}\n              textColor=\"primary\"\n              indicatorColor=\"primary\"\n              onChange={handleTabChange}\n            >\n              {tabItems.map(tabItem => (\n                <Tab\n                  label={t(`FolderCustomizer.tabs.${tabItem}`)}\n                  key={tabItem}\n                />\n              ))}\n            </Tabs>\n          </Grid>\n\n          <Grid\n            item\n            container\n            justifyContent=\"center\"\n            direction=\"column\"\n            className={styles['foldercustomizer-tabs-container']}\n          >\n            {tabItems[selectedTab] === 'colors' && (\n              <Grid item className=\"u-ta-center u-mt-3-t\">\n                <Typography\n                  variant=\"h6\"\n                  className=\"u-mt-1 u-mb-1-t u-mb-1-half\"\n                >\n                  {t('FolderCustomizer.description')}\n                </Typography>\n                <ColorPicker\n                  selectedColor={selectedColor}\n                  onColorSelect={handleColorSelect}\n                />\n              </Grid>\n            )}\n            {tabItems[selectedTab] === 'icons' && (\n              <Grid\n                item\n                ref={iconContainerRef}\n                className={styles['foldercustomizer-icons-container']}\n              >\n                <IconPicker\n                  selectedIcon={selectedIcon}\n                  onIconSelect={handleIconSelect}\n                  onIconColorSelect={handleIconColorSelect}\n                  scrollContainerRef={iconContainerRef}\n                />\n              </Grid>\n            )}\n          </Grid>\n        </Grid>\n      }\n      actions={\n        <>\n          <Buttons\n            label={t('FolderCustomizer.cancel')}\n            onClick={onClose}\n            variant=\"secondary\"\n          />\n          <Buttons label={t('FolderCustomizer.apply')} onClick={handleApply} />\n        </>\n      }\n    />\n  )\n}\n"
  },
  {
    "path": "src/modules/views/Folder/FolderDuplicateView.tsx",
    "content": "import React, { FC } from 'react'\nimport { Navigate, useLocation, useNavigate } from 'react-router-dom'\n\nimport { hasQueryBeenLoaded, useQuery } from 'cozy-client'\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport flag from 'cozy-flags'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport useDisplayedFolder from '@/hooks/useDisplayedFolder'\nimport { DuplicateModal } from '@/modules/duplicate/components/DuplicateModal'\nimport { buildParentsByIdsQuery } from '@/queries'\n\nconst FolderDuplicateView: FC = () => {\n  const navigate = useNavigate()\n  const { state } = useLocation() as {\n    state: { fileIds?: string[] }\n  }\n  const { displayedFolder } = useDisplayedFolder()\n\n  const hasFileIds = state.fileIds != undefined\n\n  const fileQuery = buildParentsByIdsQuery(state.fileIds ?? [])\n  const fileResult = useQuery(fileQuery.definition, {\n    ...fileQuery.options,\n    enabled: hasFileIds\n  }) as {\n    data?: IOCozyFile[] | null\n  }\n\n  if (!hasFileIds) {\n    return <Navigate to=\"..\" replace={true} />\n  }\n\n  if (hasQueryBeenLoaded(fileResult) && fileResult.data && displayedFolder) {\n    const onClose = (): void => {\n      navigate('..', { replace: true })\n    }\n\n    return (\n      <DuplicateModal\n        showNextcloudFolder={!flag('drive.hide-nextcloud-dev')}\n        currentFolder={displayedFolder}\n        entries={fileResult.data}\n        onClose={onClose}\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nexport { FolderDuplicateView }\n"
  },
  {
    "path": "src/modules/views/Folder/FolderView.jsx",
    "content": "import React from 'react'\n\nimport { RealTimeQueries } from 'cozy-client'\n\nimport { NotFound } from '@/components/Error/NotFound'\nimport FilesRealTimeQueries from '@/components/FilesRealTimeQueries'\nimport { ModalStack } from '@/lib/ModalContext'\nimport { ModalManager } from '@/lib/react-cozy-helpers'\nimport Main from '@/modules/layout/Main'\n\n/**\n * Renders the FolderView component.\n *\n * @component\n * @param {Object} props - The component props.\n * @param {ReactNode} props.children - The child components to render.\n * @param {boolean} props.isNotFound - Indicates if the folder is not found.\n * @returns {ReactNode} The rendered FolderView component.\n */\nconst FolderView = ({ children, isNotFound }) => (\n  <Main>\n    <FilesRealTimeQueries />\n    <RealTimeQueries doctype=\"io.cozy.settings\" />\n    <ModalStack />\n    <ModalManager />\n    {isNotFound ? <NotFound /> : children}\n  </Main>\n)\n\nexport default React.memo(FolderView)\n"
  },
  {
    "path": "src/modules/views/Folder/FolderViewBody.jsx",
    "content": "import cx from 'classnames'\nimport React, { useContext, useState, useEffect, useRef } from 'react'\nimport { useSelector } from 'react-redux'\n\nimport { isSharingShortcut } from 'cozy-client/dist/models/file'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { useFileSorting } from './hooks/useFileSorting'\nimport { useSyncingFakeFile } from './useSyncingFakeFile'\n\nimport styles from '@/styles/folder-view.styl'\n\nimport { EmptyWrapper } from '@/components/Error/Empty'\nimport Oops from '@/components/Error/Oops'\nimport RightClickFileMenu from '@/components/RightClick/RightClickFileMenu'\nimport { useShiftSelection } from '@/hooks/useShiftSelection'\nimport AcceptingSharingContext from '@/lib/AcceptingSharingContext'\nimport { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext'\nimport { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'\nimport AddFolder from '@/modules/filelist/AddFolder'\nimport { FileWithSelection as File } from '@/modules/filelist/File'\nimport { FileList } from '@/modules/filelist/FileList'\nimport FileListBody from '@/modules/filelist/FileListBody'\nimport { FileListHeader } from '@/modules/filelist/FileListHeader'\nimport FileListRowsPlaceholder from '@/modules/filelist/FileListRowsPlaceholder'\nimport LoadMore from '@/modules/filelist/LoadMoreV2'\nimport { isTypingNewFolderName } from '@/modules/filelist/duck'\nimport SelectionBar from '@/modules/selection/SelectionBar'\nimport { isReferencedByShareInSharingContext } from '@/modules/views/Folder/syncHelpers'\n\nconst FileListBodyWrapper = ({ viewType, children }) => {\n  return (\n    <div\n      className={cx(viewType === 'grid' ? styles['fil-folder-body-grid'] : '')}\n    >\n      {children}\n    </div>\n  )\n}\n\n// TODO: extraColumns is then passed to 'FileListHeader', 'AddFolder',\n// and 'File' (this one from a 'syncingFakeFile' and a normal file).\n// It is easy to forget to update one of these components to pass 'extraColumns'.\n// It would be ideal to centralize it somewhere.\nconst FolderViewBody = ({\n  currentFolderId,\n  displayedFolder,\n  queryResults,\n  actions,\n  canSort,\n  canUpload = true,\n  withFilePath = false,\n  refreshFolderContent = null,\n  extraColumns,\n  orderProps,\n  driveId\n}) => {\n  const { isDesktop } = useBreakpoints()\n  const { viewType, switchView } = useViewSwitcherContext()\n  const folderViewRef = useRef()\n  const IsAddingFolder = useSelector(isTypingNewFolderName)\n\n  const { sortOrder, isSettingsLoaded, sortedFiles, changeSortOrder } =\n    useFileSorting(currentFolderId, queryResults, orderProps)\n\n  const { setLastInteractedItem, onShiftClick } = useShiftSelection(\n    { items: sortedFiles, viewType },\n    folderViewRef\n  )\n\n  /**\n   *  Since we are not able to restore the scroll correctly,\n   * and force the scroll to top every time we change the\n   * current folder. This is to avoid this kind of weird\n   * behavior:\n   * - If I go to a sub-folder, if this subfolder has a lot\n   * of data and I scrolled down until the bottom. If I go\n   * back, then my folder will also be scrolled down.\n   *\n   * This is an ugly hack, yeah.\n   * */\n  useEffect(() => {\n    if (isDesktop) {\n      const scrollable = document.querySelectorAll(\n        '[data-testid=fil-content-body]'\n      )[0]\n      if (scrollable) {\n        scrollable.scroll({ top: 0 })\n      }\n    } else {\n      window.scroll({ top: 0 })\n    }\n  }, [currentFolderId, isDesktop])\n\n  const { isBigThumbnail } = useThumbnailSizeContext()\n  const { sharingsValue } = useContext(AcceptingSharingContext)\n\n  const isInError = queryResults.some(query => query.fetchStatus === 'failed')\n  const hasDataToShow =\n    !isInError &&\n    queryResults.some(query => query.data && query.data.length > 0)\n  const isLoading =\n    !hasDataToShow &&\n    queryResults.some(\n      query => query.fetchStatus === 'loading' && !query.lastUpdate\n    ) &&\n    !isSettingsLoaded\n  const isEmpty = !isInError && !isLoading && !hasDataToShow\n  const showEmpty = displayedFolder !== null && !IsAddingFolder && isEmpty\n  const isSharingContextEmpty = Object.keys(sharingsValue).length <= 0\n\n  const { syncingFakeFile } = useSyncingFakeFile({ isEmpty, queryResults })\n\n  const onToggleSelect = (fileId, e) => {\n    setLastInteractedItem(fileId)\n    onShiftClick(fileId, e)\n  }\n\n  /**\n   * When we mount the component when we already have data in cache,\n   * the mount is time consuming since we'll render at least 100 lines\n   * of File.\n   *\n   * React seems to batch together the fact that :\n   * - we change a route\n   * - we want to render 100 files\n   * resulting in a non smooth transition between views (Drive / Recent / ...)\n   *\n   * In order to bypass this batch, we use a state to first display a much\n   * more simpler component and then the files\n   */\n  const [needsToWait, setNeedsToWait] = useState(true)\n  useEffect(() => {\n    let timeout = null\n    if (!isLoading) {\n      timeout = setTimeout(() => {\n        setNeedsToWait(false)\n      }, 50)\n    }\n    return () => clearTimeout(timeout)\n  }, [isLoading])\n\n  return (\n    <>\n      <SelectionBar actions={actions} />\n      <FileList ref={folderViewRef}>\n        {hasDataToShow && (\n          <FileListHeader\n            folderId={null}\n            canSort={canSort}\n            sort={sortOrder}\n            onFolderSort={changeSortOrder}\n            viewType={viewType}\n            switchViewType={switchView}\n            extraColumns={extraColumns}\n          />\n        )}\n        <FileListBody selectionModeActive={false}>\n          {!hasDataToShow && !needsToWait && (\n            <FileListBodyWrapper viewType={viewType} isDesktop={isDesktop}>\n              <AddFolder\n                refreshFolderContent={refreshFolderContent}\n                extraColumns={extraColumns}\n                currentFolderId={currentFolderId}\n              />\n            </FileListBodyWrapper>\n          )}\n          {isInError && <Oops />}\n          {(needsToWait || isLoading) && <FileListRowsPlaceholder />}\n          {/* TODO FolderViewBody should not have the responsability to chose\n          which empty component to display. It should be done by the \"view\" itself.\n          But adding a new prop like <FolderViewBody emptyComponent={}\n          is not good enought too */}\n          {showEmpty && (\n            <EmptyWrapper\n              currentFolderId={currentFolderId}\n              canUpload={canUpload}\n              driveId={driveId}\n            />\n          )}\n          {hasDataToShow && !needsToWait && (\n            <FileListBodyWrapper viewType={viewType} isDesktop={isDesktop}>\n              <>\n                {syncingFakeFile && (\n                  <File\n                    attributes={syncingFakeFile}\n                    withSelectionCheckbox={false}\n                    actions={[]}\n                    isInSyncFromSharing={true}\n                    extraColumns={extraColumns}\n                    disableSelection={true}\n                  />\n                )}\n                <AddFolder\n                  refreshFolderContent={refreshFolderContent}\n                  extraColumns={extraColumns}\n                  currentFolderId={currentFolderId}\n                />\n                {sortedFiles.map(file => {\n                  return (\n                    <RightClickFileMenu\n                      key={file._id}\n                      doc={file}\n                      actions={actions}\n                    >\n                      <File\n                        key={file._id}\n                        attributes={file}\n                        withSelectionCheckbox\n                        withFilePath={withFilePath}\n                        thumbnailSizeBig={isBigThumbnail}\n                        actions={actions}\n                        refreshFolderContent={refreshFolderContent}\n                        isInSyncFromSharing={\n                          !isSharingContextEmpty &&\n                          isSharingShortcut(file) &&\n                          isReferencedByShareInSharingContext(\n                            file,\n                            sharingsValue\n                          )\n                        }\n                        extraColumns={extraColumns}\n                        onToggleSelect={e => {\n                          onToggleSelect(file?._id, e)\n                        }}\n                      />\n                    </RightClickFileMenu>\n                  )\n                })}\n                {queryResults.some(query => query.hasMore) && (\n                  <LoadMore\n                    fetchMore={() => {\n                      queryResults.forEach(query => {\n                        if (query.hasMore && query.fetchMore) {\n                          query.fetchMore()\n                        }\n                      })\n                    }}\n                  />\n                )}\n              </>\n            </FileListBodyWrapper>\n          )}\n        </FileListBody>\n      </FileList>\n    </>\n  )\n}\n\nexport default FolderViewBody\n"
  },
  {
    "path": "src/modules/views/Folder/FolderViewBreadcrumb.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React, { useCallback } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'\nimport { useBreadcrumbPath } from '@/modules/breadcrumb/hooks/useBreadcrumbPath.jsx'\n\nconst FolderViewBreadcrumb = ({\n  currentFolderId,\n  rootBreadcrumbPath,\n  sharedDocumentIds\n}) => {\n  const navigate = useNavigate()\n  const path = useBreadcrumbPath({\n    currentFolderId,\n    rootBreadcrumbPath,\n    sharedDocumentIds\n  })\n\n  const onBreadcrumbClick = useCallback(\n    ({ id }) => {\n      navigate(id ? `../${id}` : '..', {\n        relative: 'path'\n      })\n    },\n    [navigate]\n  )\n\n  return path && path.length > 0 ? (\n    <Breadcrumb\n      path={path}\n      onBreadcrumbClick={onBreadcrumbClick}\n      opening={false}\n    />\n  ) : null\n}\n\nFolderViewBreadcrumb.propTypes = {\n  currentFolderId: PropTypes.string.isRequired,\n  rootBreadcrumbPath: PropTypes.exact({\n    id: PropTypes.string,\n    name: PropTypes.string\n  }).isRequired,\n  sharedDocumentIds: PropTypes.array\n}\n\nexport default FolderViewBreadcrumb\n"
  },
  {
    "path": "src/modules/views/Folder/FolderViewBreadcrumb.spec.jsx",
    "content": "import { render } from '@testing-library/react'\nimport React from 'react'\n\nimport FolderViewBreadcrumb from './FolderViewBreadcrumb'\nimport {\n  dummyBreadcrumbPathWithRootLarge,\n  dummyRootBreadcrumbPath\n} from 'test/dummies/dummyBreadcrumbPath'\n\nimport { useBreadcrumbPath } from '@/modules/breadcrumb/hooks/useBreadcrumbPath'\n\njest.mock('modules/breadcrumb/hooks/useBreadcrumbPath')\njest.mock('modules/breadcrumb/components/MobileAwareBreadcrumb', () => ({\n  MobileAwareBreadcrumb: ({ path, opening }) => (\n    <div\n      data-testid=\"MobileAwareBreadcrumb\"\n      data-path={path}\n      data-opening={opening ? 'true' : 'false'}\n    />\n  )\n}))\njest.mock('react-router-dom', () => ({\n  useNavigate: jest.fn()\n}))\n\ndescribe('FolderViewBreadcrumb', () => {\n  const rootBreadcrumbPath = dummyRootBreadcrumbPath()\n\n  it('should use breadcrumb path', () => {\n    // Given\n    const currentFolderId = '1234'\n    const sharedDocumentIds = [currentFolderId, '5678']\n\n    // When\n    render(\n      <FolderViewBreadcrumb\n        currentFolderId={currentFolderId}\n        rootBreadcrumbPath={rootBreadcrumbPath}\n        sharedDocumentIds={sharedDocumentIds}\n      />\n    )\n\n    // Then\n    expect(useBreadcrumbPath).toHaveBeenCalledWith({\n      currentFolderId,\n      rootBreadcrumbPath,\n      sharedDocumentIds\n    })\n  })\n\n  it('should set correct path in template', () => {\n    // Given\n    useBreadcrumbPath.mockReturnValue(dummyBreadcrumbPathWithRootLarge())\n\n    // When\n    const { getByTestId } = render(\n      <FolderViewBreadcrumb\n        currentFolderId=\"1234\"\n        rootBreadcrumbPath={rootBreadcrumbPath}\n      />\n    )\n\n    // Then\n    expect(getByTestId('MobileAwareBreadcrumb')).toBeTruthy()\n    expect(\n      getByTestId('MobileAwareBreadcrumb').hasAttribute('data-path')\n    ).toEqual(true)\n    expect(\n      getByTestId('MobileAwareBreadcrumb').getAttribute('data-opening')\n    ).toEqual('false')\n  })\n\n  it('should be null when path empty', () => {\n    // Given\n    useBreadcrumbPath.mockReturnValue([])\n\n    // When\n    const { container } = render(\n      <FolderViewBreadcrumb\n        currentFolderId=\"1234\"\n        rootBreadcrumbPath={rootBreadcrumbPath}\n      />\n    )\n\n    // Then\n    expect(container).toMatchInlineSnapshot(`<div />`)\n  })\n\n  it('should be null when path undefined', () => {\n    // Given\n    useBreadcrumbPath.mockReturnValue()\n\n    // When\n    const { container } = render(\n      <FolderViewBreadcrumb\n        currentFolderId=\"1234\"\n        rootBreadcrumbPath={rootBreadcrumbPath}\n      />\n    )\n\n    // Then\n    expect(container).toMatchInlineSnapshot(`<div />`)\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Folder/FolderViewHeader.jsx",
    "content": "import React from 'react'\n\nimport Topbar from '@/modules/layout/Topbar'\n\nconst FolderViewHeader = ({ children }) => {\n  return <Topbar>{children}</Topbar>\n}\n\nexport default FolderViewHeader\n"
  },
  {
    "path": "src/modules/views/Folder/OldFolderViewBreadcrumb.jsx",
    "content": "import React, { useCallback, useEffect, useState } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\n\nimport logger from '@/lib/logger'\nimport { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'\n\nconst FolderViewBreadcrumb = ({\n  displayedFolder,\n  sharedDocumentId,\n  getBreadcrumbPath\n}) => {\n  const navigate = useNavigate()\n  const client = useClient()\n  const [path, setPath] = useState(null)\n\n  useEffect(() => {\n    let isMounted = true\n\n    // eslint-disable-next-line react-hooks/set-state-in-effect\n    setPath(null)\n    if (!displayedFolder || !sharedDocumentId) return\n\n    const asyncGetPaths = async () => {\n      try {\n        const paths = await getBreadcrumbPath({\n          client,\n          displayedFolder,\n          sharedDocumentId\n        })\n        if (isMounted) {\n          setPath(paths)\n        }\n      } catch (error) {\n        logger.error(`Error while fetching breadcrumb path: ${error}`)\n        if (isMounted) {\n          setPath(null)\n        }\n      }\n    }\n\n    asyncGetPaths()\n\n    return () => {\n      isMounted = false\n    }\n  }, [displayedFolder, sharedDocumentId, client, getBreadcrumbPath])\n\n  const onBreadcrumbClick = useCallback(\n    ({ id }) => {\n      navigate(id ? `../${id}` : '..', {\n        relative: 'path'\n      })\n    },\n    [navigate]\n  )\n\n  return path ? (\n    <Breadcrumb\n      path={path}\n      onBreadcrumbClick={onBreadcrumbClick}\n      opening={false}\n    />\n  ) : null\n}\n\nexport default FolderViewBreadcrumb\n"
  },
  {
    "path": "src/modules/views/Folder/PublicFolderDuplicateView.tsx",
    "content": "import React, { FC } from 'react'\nimport { Navigate, useLocation, useNavigate } from 'react-router-dom'\n\nimport usePublicFileByIdsQuery from '../Public/usePublicFileByIdsQuery'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport useDisplayedFolder from '@/hooks/useDisplayedFolder'\nimport { DuplicateModal } from '@/modules/duplicate/components/DuplicateModal'\n\nconst PublicFolderDuplicateView: FC = () => {\n  const navigate = useNavigate()\n  const { state } = useLocation() as {\n    state: { fileIds?: string[] }\n  }\n  const { displayedFolder } = useDisplayedFolder()\n\n  const hasFileIds = state.fileIds != undefined\n\n  const { files, fetchStatus } = usePublicFileByIdsQuery(\n    state.fileIds ?? ([] as string[])\n  )\n\n  if (!hasFileIds) {\n    return <Navigate to=\"..\" replace={true} />\n  }\n\n  if (fetchStatus === 'loaded' && files.length && displayedFolder) {\n    const onClose = (): void => {\n      navigate('..', { replace: true })\n    }\n\n    return (\n      <DuplicateModal\n        currentFolder={displayedFolder}\n        entries={files}\n        onClose={onClose}\n        isPublic\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nexport { PublicFolderDuplicateView }\n"
  },
  {
    "path": "src/modules/views/Folder/helpers.js",
    "content": "import { SHARED_DRIVES_DIR_ID } from '@/constants/config'\nimport { getDriveI18n } from '@/locales'\n\n/**\n * Converts a hex color string to an RGB object.\n *\n * @param {string} hex - The hex color string (e.g., \"#ff8800\" or \"ff8800\").\n * @returns {{r: number, g: number, b: number}|null} An object with r, g, b values (0-255), or null if input is invalid.\n */\nexport const hexToRgb = hex => {\n  if (!hex) return null\n  const normalized = hex.replace(/^#/, '')\n  const long = /^(\\w{2})(\\w{2})(\\w{2})$/i\n  const match = normalized.match(long)\n  if (!match) return null\n  const [, r, g, b] = match\n  return {\n    r: parseInt(r, 16),\n    g: parseInt(g, 16),\n    b: parseInt(b, 16)\n  }\n}\n\n/**\n * Converts an RGB object to a hex color string.\n *\n * @param {{r: number, g: number, b: number}} param - The RGB object.\n * @returns {string} The hex color string (e.g., \"#ff8800\" or \"ff8800\").\n */\nexport const rgbToHex = ({ r, g, b }) => {\n  const toHex = n => n.toString(16).padStart(2, '0')\n  return `#${toHex(Math.max(0, Math.min(255, r)))}${toHex(\n    Math.max(0, Math.min(255, g))\n  )}${toHex(Math.max(0, Math.min(255, b)))}`\n}\n\n/**\n * Mixes two RGB objects with a given factor.\n *\n * @param {{r: number, g: number, b: number}} colorRgb - The color RGB object.\n * @param {{r: number, g: number, b: number}} mixRgb - The mix RGB object.\n * @param {number} factor - The factor (0-1).\n * @returns {{r: number, g: number, b: number}} The mixed RGB object.\n */\nexport const mixWith = (colorRgb, mixRgb, factor) => {\n  // Linear blend: result = color * (1 - f) + mix * f\n  const f = Math.max(0, Math.min(1, factor))\n  return {\n    r: Math.round(colorRgb.r * (1 - f) + mixRgb.r * f),\n    g: Math.round(colorRgb.g * (1 - f) + mixRgb.g * f),\n    b: Math.round(colorRgb.b * (1 - f) + mixRgb.b * f)\n  }\n}\n\n/**\n * Generates a shade of a given hex color string.\n *\n * @param {string} baseHex - The base hex color string.\n * @param {{to?: 'white' | 'black', factor?: number}} options - The options.\n * @returns {string} The shaded hex color string.\n */\nexport const shadeColor = (baseHex, { to = 'white', factor = 0.2 } = {}) => {\n  const base = hexToRgb(baseHex)\n  if (!base) return baseHex\n  const target =\n    to === 'black' ? { r: 0, g: 0, b: 0 } : { r: 255, g: 255, b: 255 }\n  const mixed = mixWith(base, target, factor)\n  return rgbToHex(mixed)\n}\n\nexport const makeColumns = isBigThumbnail => {\n  const { t } = getDriveI18n()\n\n  return [\n    {\n      id: 'name',\n      maxWidth: 0,\n      disablePadding: !isBigThumbnail,\n      label: t('table.head_name')\n    },\n    {\n      id: 'updated_at',\n      disablePadding: false,\n      width: 160,\n      label: t('table.head_update'),\n      textAlign: 'right'\n    },\n    {\n      id: 'size',\n      disablePadding: false,\n      width: 80,\n      label: t('table.head_size'),\n      textAlign: 'right'\n    },\n    {\n      id: 'share',\n      disablePadding: false,\n      width: 125,\n      label: t('table.head_status'),\n      textAlign: 'right',\n      sortable: false\n    },\n    {\n      id: 'menu',\n      disablePadding: false,\n      width: 60,\n      label: '',\n      textAlign: 'center',\n      sortable: false\n    }\n  ]\n}\n\n/**\n * Sort files by type to put directory and trash before files\n * @param {import('cozy-client/types').IOCozyFile[]} file\n * @returns {import('cozy-client/types').IOCozyFile[]}\n */\nexport const secondarySort = file => {\n  const { tempFolder, folders, files, trashFolder } = file.reduce(\n    (acc, el) => {\n      if (el?.type === 'tempDirectory') {\n        acc.tempFolder.push(el)\n      } else if (el?.type === 'directory') {\n        if (el?.name === '.cozy_trash') {\n          acc.trashFolder.push(el)\n        } else if (el?._id === SHARED_DRIVES_DIR_ID) {\n          acc.folders.unshift(el)\n        } else {\n          acc.folders.push(el)\n        }\n      } else if (el?.type === 'file') {\n        acc.files.push(el)\n      }\n      return acc\n    },\n    { tempFolder: [], folders: [], files: [], trashFolder: [] }\n  )\n\n  return [...tempFolder, ...folders, ...trashFolder, ...files]\n}\n"
  },
  {
    "path": "src/modules/views/Folder/helpers.spec.js",
    "content": "import { hexToRgb, rgbToHex, mixWith, shadeColor } from './helpers'\n\n// Mock locales to make labels deterministic in tests\njest.mock('@/locales', () => ({\n  getDriveI18n: () => ({ t: k => k })\n}))\n\ndescribe('color helpers', () => {\n  test('hexToRgb returns null on falsy/invalid inputs', () => {\n    expect(hexToRgb('')).toBeNull()\n    expect(hexToRgb(null)).toBeNull()\n    expect(hexToRgb(undefined)).toBeNull()\n    expect(hexToRgb('zzz')).toBeNull()\n    expect(hexToRgb('#1234')).toBeNull()\n  })\n\n  test('hexToRgb parses 6-digit hex with or without #', () => {\n    expect(hexToRgb('#ff8800')).toEqual({ r: 255, g: 136, b: 0 })\n    expect(hexToRgb('ff8800')).toEqual({ r: 255, g: 136, b: 0 })\n    expect(hexToRgb('#000000')).toEqual({ r: 0, g: 0, b: 0 })\n    expect(hexToRgb('#ffffff')).toEqual({ r: 255, g: 255, b: 255 })\n  })\n\n  test('rgbToHex converts and clamps values into #rrggbb', () => {\n    expect(rgbToHex({ r: 255, g: 136, b: 0 })).toBe('#ff8800')\n    expect(rgbToHex({ r: 0, g: 0, b: 0 })).toBe('#000000')\n    expect(rgbToHex({ r: 255, g: 255, b: 255 })).toBe('#ffffff')\n    // Clamp below 0 and above 255\n    expect(rgbToHex({ r: -20, g: 500, b: 10 })).toBe('#00ff0a')\n  })\n\n  test('mixWith blends two colors linearly by factor', () => {\n    const red = { r: 255, g: 0, b: 0 }\n    const blue = { r: 0, g: 0, b: 255 }\n    expect(mixWith(red, blue, 0)).toEqual({ r: 255, g: 0, b: 0 })\n    expect(mixWith(red, blue, 1)).toEqual({ r: 0, g: 0, b: 255 })\n    expect(mixWith(red, blue, 0.5)).toEqual({ r: 128, g: 0, b: 128 })\n    // Factor is clamped to [0, 1]\n    expect(mixWith(red, blue, -1)).toEqual({ r: 255, g: 0, b: 0 })\n    expect(mixWith(red, blue, 2)).toEqual({ r: 0, g: 0, b: 255 })\n  })\n\n  test('shadeColor mixes towards white or black', () => {\n    expect(shadeColor('#000000', { to: 'white', factor: 0.5 })).toBe('#808080')\n    expect(shadeColor('#ffffff', { to: 'black', factor: 0.5 })).toBe('#808080')\n    expect(shadeColor('#ff0000', { to: 'white', factor: 0.5 })).toBe('#ff8080')\n    expect(shadeColor('#00ff00', { to: 'black', factor: 0.5 })).toBe('#008000')\n    // Fallback to input if base is invalid\n    expect(shadeColor('zzz', { to: 'white', factor: 0.5 })).toBe('zzz')\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Folder/hooks/useFileSorting.js",
    "content": "import { useMemo, useCallback } from 'react'\n\nimport {\n  stableSort,\n  getComparator\n} from 'cozy-ui/transpiled/react/Table/Virtualized/helpers'\n\nimport { secondarySort } from '../helpers'\n\nimport { useFolderSort } from '@/hooks'\n\n/**\n * Custom hook for handling file sorting logic\n * @param {string} currentFolderId - The current folder ID\n * @param {Array} queryResults - Query results containing files\n * @param {Object} orderProps - External order properties (optional)\n * @returns {Object} Sorting state and functions\n */\nexport const useFileSorting = (currentFolderId, queryResults, orderProps) => {\n  // Get internal sorting state from existing hook\n  const [internalSortOrder, internalSetSortOrder, internalIsSettingsLoaded] =\n    useFolderSort(currentFolderId)\n\n  // Merge internal and external sort properties\n  const sortOrder = orderProps?.sortOrder ?? internalSortOrder\n  const setSortOrder = orderProps?.setOrder ?? internalSetSortOrder\n  const isSettingsLoaded =\n    orderProps?.isSettingsLoaded ?? internalIsSettingsLoaded\n\n  // Extract all files from query results\n  const allFiles = useMemo(() => {\n    const files = []\n    queryResults.forEach(query => {\n      if (query.data && query.data.length > 0) {\n        files.push(...query.data)\n      }\n    })\n    return files\n  }, [queryResults])\n\n  // Sort files based on current sort order\n  const sortedFiles = useMemo(() => {\n    const { order, attribute: orderBy } = sortOrder\n    if (!order || !orderBy) {\n      return secondarySort(allFiles)\n    }\n    const sortedData = stableSort(allFiles, getComparator(order, orderBy))\n    return secondarySort(sortedData)\n  }, [allFiles, sortOrder])\n\n  // Create sort change handler\n  const changeSortOrder = useCallback(\n    (_, attribute, order) => setSortOrder({ attribute, order }),\n    [setSortOrder]\n  )\n\n  return {\n    sortOrder,\n    setSortOrder,\n    isSettingsLoaded,\n    allFiles,\n    sortedFiles,\n    changeSortOrder\n  }\n}\n"
  },
  {
    "path": "src/modules/views/Folder/hooks/useFileSorting.spec.js",
    "content": "import { renderHook } from '@testing-library/react'\n\nimport { useFileSorting } from './useFileSorting'\n\n// Mock des dépendances\njest.mock('@/hooks', () => ({\n  useFolderSort: jest.fn(() => [\n    { order: 'asc', attribute: 'name' },\n    jest.fn(),\n    true\n  ])\n}))\n\njest.mock('cozy-ui/transpiled/react/Table/Virtualized/helpers', () => ({\n  stableSort: jest.fn((data, comparator) => [...data].sort(comparator)),\n  getComparator: jest.fn((order, orderBy) => (a, b) => {\n    if (order === 'asc') {\n      return a[orderBy]?.localeCompare(b[orderBy])\n    }\n    return b[orderBy]?.localeCompare(a[orderBy])\n  })\n}))\n\ndescribe('useFileSorting', () => {\n  const mockQueryResults = [\n    {\n      data: [\n        {\n          _id: '1',\n          name: 'file-b.txt',\n          type: 'file',\n          updated_at: '2023-01-01T10:00:00Z'\n        },\n        {\n          _id: '2',\n          name: 'file-a.txt',\n          type: 'file',\n          updated_at: '2023-01-01T11:00:00Z'\n        },\n        {\n          _id: '3',\n          name: 'folder-c',\n          type: 'directory',\n          updated_at: '2023-01-01T12:00:00Z'\n        }\n      ]\n    }\n  ]\n\n  const mockOrderProps = {\n    sortOrder: { order: 'desc', attribute: 'updated_at' },\n    setOrder: jest.fn(),\n    isSettingsLoaded: true\n  }\n\n  afterEach(() => {\n    jest.clearAllMocks()\n  })\n\n  it('should extract all files from query results', () => {\n    const { result } = renderHook(() =>\n      useFileSorting('folder-1', mockQueryResults, {})\n    )\n\n    expect(result.current.allFiles).toHaveLength(3)\n    expect(result.current.allFiles[0]._id).toBe('1')\n  })\n\n  it('should use internal sort order when no orderProps provided', () => {\n    const { result } = renderHook(() =>\n      useFileSorting('folder-1', mockQueryResults, {})\n    )\n\n    expect(result.current.sortOrder).toEqual({\n      order: 'asc',\n      attribute: 'name'\n    })\n  })\n\n  it('should use external sort order when orderProps provided', () => {\n    const { result } = renderHook(() =>\n      useFileSorting('folder-1', mockQueryResults, mockOrderProps)\n    )\n\n    expect(result.current.sortOrder).toEqual({\n      order: 'desc',\n      attribute: 'updated_at'\n    })\n  })\n\n  it('should apply secondary sort (directories before files)', () => {\n    const { result } = renderHook(() =>\n      useFileSorting(\n        'folder-1',\n        [\n          {\n            data: [\n              { _id: '1', name: 'file.txt', type: 'file' },\n              { _id: '2', name: 'folder', type: 'directory' }\n            ]\n          }\n        ],\n        {}\n      )\n    )\n\n    // Secondary sort should put directories before files\n    expect(result.current.sortedFiles[0].type).toBe('directory')\n    expect(result.current.sortedFiles[1].type).toBe('file')\n  })\n\n  it('should apply both primary and secondary sort', () => {\n    const { result } = renderHook(() =>\n      useFileSorting('folder-1', mockQueryResults, mockOrderProps)\n    )\n\n    // Should be sorted by updated_at in descending order (most recent first), then by type\n    expect(result.current.sortedFiles).toHaveLength(3)\n\n    // Verify the actual sort ordering\n    const sortedFiles = result.current.sortedFiles\n\n    // Expected order: updated_at descending (folder-c:12:00, file-a.txt:11:00, file-b.txt:10:00)\n    // Secondary sort: directories before files (folder-c should come before files)\n    expect(sortedFiles[0].name).toBe('folder-c')\n    expect(sortedFiles[0].type).toBe('directory')\n\n    expect(sortedFiles[1].name).toBe('file-a.txt')\n    expect(sortedFiles[1].type).toBe('file')\n\n    expect(sortedFiles[2].name).toBe('file-b.txt')\n    expect(sortedFiles[2].type).toBe('file')\n\n    // Alternative verification: map filenames and types\n    const fileInfo = sortedFiles.map(file => ({\n      name: file.name,\n      type: file.type\n    }))\n    expect(fileInfo).toEqual([\n      { name: 'folder-c', type: 'directory' },\n      { name: 'file-a.txt', type: 'file' },\n      { name: 'file-b.txt', type: 'file' }\n    ])\n  })\n\n  it('should create changeSortOrder callback', () => {\n    const { result } = renderHook(() =>\n      useFileSorting('folder-1', mockQueryResults, mockOrderProps)\n    )\n\n    result.current.changeSortOrder(null, 'size', 'asc')\n    expect(mockOrderProps.setOrder).toHaveBeenCalledWith({\n      attribute: 'size',\n      order: 'asc'\n    })\n  })\n\n  it('should handle empty query results', () => {\n    const { result } = renderHook(() => useFileSorting('folder-1', [], {}))\n\n    expect(result.current.allFiles).toHaveLength(0)\n    expect(result.current.sortedFiles).toHaveLength(0)\n  })\n\n  it('should handle query results with empty data arrays', () => {\n    const { result } = renderHook(() =>\n      useFileSorting(\n        'folder-1',\n        [{ data: [] }, { data: null }, { data: undefined }],\n        {}\n      )\n    )\n\n    expect(result.current.allFiles).toHaveLength(0)\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Folder/syncHelpers.js",
    "content": "import get from 'lodash/get'\n\n/**\n * Whether there is a file referenced by a share id\n * @param {array} queryResults - List of folders and files\n * @param {string} sharingId - Id of an io.cozy.sharings doc\n * @returns {bool} true|false\n */\nexport const isThereFileReferencedBySharingId = (queryResults, sharingId) => {\n  return queryResults.some(query => {\n    return query.data.some(file => {\n      const fileReferences =\n        file.referenced_by && file.referenced_by.length >= 1\n      if (fileReferences) {\n        return file.referenced_by.some(reference => {\n          if (reference.type === 'io.cozy.sharings') {\n            return reference.id === sharingId\n          }\n          return false\n        })\n      }\n      return false\n    })\n  })\n}\n\n/**\n * Remove a share from sharing context\n * @param {object} params - Params\n * @param {object} params.sharingsValue - Sharing Context value\n * @param {function} params.setSharingsValue - Sharing Context setter\n * @param {string} params.sharingId - Id of an io.cozy.sharings doc\n */\nexport const removeSharingFromContext = ({\n  sharingsValue,\n  setSharingsValue,\n  sharingId\n}) => {\n  delete sharingsValue[sharingId]\n  setSharingsValue(sharingsValue)\n}\n\n/**\n * Create a syncing fake file, necessary in the case of sharing without shortcut\n * This fake file shows a spinner the time it takes to recover the real file\n * @param {object} params - Params\n * @param {string} params.sharingId - Id of an io.cozy.sharings doc\n * @param {object} params.sharingsValue - Sharing Context value\n * @param {object} params.fileValue - Sharing Context file value\n * @returns {object} Syncing fake file\n */\nexport const createSyncingFakeFile = ({ sharingValue }) => {\n  if (!sharingValue) {\n    return null\n  }\n  return {\n    name: sharingValue.attributes.description,\n    id: sharingValue.id,\n    type: 'directory'\n  }\n}\n\n/**\n * Returns syncingFakeFile if it is still needed, otherwise remove the share in context\n * @param {object} params - Params\n * @param {array} params.queryResults - List of folders and files\n * @param {string} params.sharingId - Id of an io.cozy.sharings doc\n * @param {object} params.sharingsValue - Sharing Context value\n * @param {function} params.setSharingsValue - Sharing Context setter\n * @param {object} params.syncingFakeFile - Syncing fake file\n * @returns {object} Syncing fake file or null\n */\nexport const checkSyncingFakeFileObsolescence = ({\n  queryResults,\n  sharingId,\n  sharingsValue,\n  setSharingsValue,\n  syncingFakeFile\n}) => {\n  const isThereRealtimeFileReferencedBySharing =\n    isThereFileReferencedBySharingId(queryResults, sharingId)\n\n  if (!isThereRealtimeFileReferencedBySharing) {\n    return syncingFakeFile\n  }\n\n  removeSharingFromContext({ sharingsValue, setSharingsValue, sharingId })\n  return null\n}\n\n/**\n * Create syncing fake file or check if it still longer needed\n * @param {object} params - Params\n * @param {boolean} params.isEmpty - Whether the query to fetch files returns nothing or error\n * @param {boolean} params.isSharingContextEmpty - Whether the sharing context is empty\n * @param {array} params.queryResults - List of folders and files\n * @param {string} params.sharingId - Id of an io.cozy.sharings doc\n * @param {object} params.sharingsValue - Sharing Context value\n * @param {function} params.setSharingsValue - Sharing Context setter\n * @param {object} params.fileValue - Sharing Context file value\n * @returns {object} Syncing fake file\n */\nexport const computeSyncingFakeFile = ({\n  isEmpty,\n  isSharingContextEmpty,\n  queryResults,\n  sharingId,\n  sharingsValue,\n  setSharingsValue,\n  fileValue\n}) => {\n  if (isEmpty || isSharingContextEmpty) {\n    return null\n  }\n\n  const sharingValue = sharingsValue[sharingId]\n\n  if (fileValue || !sharingValue) {\n    return null\n  }\n\n  const syncingFakeFile = createSyncingFakeFile({\n    sharingValue\n  })\n\n  const updatedSyncingFakeFile = checkSyncingFakeFileObsolescence({\n    syncingFakeFile,\n    queryResults,\n    sharingId,\n    sharingsValue,\n    setSharingsValue,\n    fileValue\n  })\n\n  return updatedSyncingFakeFile\n}\n\n/**\n * Whether the file is referenced by a share in the sharing context\n * @param {object} file - An io.cozy.files doc\n * @param {object} sharingsValue - Sharing Context value\n * @returns {bool}\n */\nexport const isReferencedByShareInSharingContext = (file, sharingsValue) => {\n  const fileReferences = get(file, 'relationships.referenced_by.data')\n  if (!fileReferences) return false\n\n  const fileSharingId = fileReferences.find(\n    reference => reference.type === 'io.cozy.sharings'\n  ).id\n\n  return get(sharingsValue, fileSharingId, false) !== false\n}\n"
  },
  {
    "path": "src/modules/views/Folder/syncHelpers.spec.js",
    "content": "import {\n  isThereFileReferencedBySharingId,\n  createSyncingFakeFile,\n  computeSyncingFakeFile,\n  checkSyncingFakeFileObsolescence,\n  isReferencedByShareInSharingContext\n} from './syncHelpers'\n\nconst queryResults = [\n  {\n    id: 'directory',\n    data: [\n      {\n        referenced_by: [\n          {\n            type: 'io.cozy.sharings',\n            id: 'directory-sharing-id'\n          },\n          {\n            type: 'io.cozy.otherType',\n            id: 'other-type-id'\n          }\n        ]\n      }\n    ]\n  },\n  {\n    id: 'file',\n    data: [\n      {\n        referenced_by: []\n      },\n      {\n        referenced_by: [\n          {\n            type: 'io.cozy.sharings',\n            id: 'file-with-sharing-id'\n          }\n        ]\n      }\n    ]\n  }\n]\n\nlet sharingsValue\n\nconst setupComputeSyncingFakeFile = ({\n  isEmpty = false,\n  isSharingContextEmpty = false,\n  queryResults,\n  sharingId = '',\n  sharingsValue,\n  setSharingsValue = jest.fn(),\n  fileValue = undefined\n} = {}) => {\n  const syncingFakeFile = computeSyncingFakeFile({\n    isEmpty,\n    isSharingContextEmpty,\n    queryResults,\n    sharingId,\n    sharingsValue,\n    setSharingsValue,\n    fileValue\n  })\n\n  return { syncingFakeFile }\n}\n\ndescribe('syncHelpers', () => {\n  beforeEach(() => {\n    sharingsValue = {\n      id1: {\n        id: 'id1',\n        attributes: {\n          description: 'fileName.ext'\n        }\n      },\n      'file-with-sharing-id': {\n        id: 'file-with-sharing-id',\n        attributes: {\n          description: 'folderName'\n        }\n      }\n    }\n  })\n\n  describe('isThereFileReferencedBySharingId', () => {\n    it('should return true if a directory is referenced by the sharing id', () => {\n      expect(\n        isThereFileReferencedBySharingId(queryResults, 'directory-sharing-id')\n      ).toBeTruthy()\n    })\n\n    it('should return true if a file is referenced by the sharing id', () => {\n      expect(\n        isThereFileReferencedBySharingId(queryResults, 'file-with-sharing-id')\n      ).toBeTruthy()\n    })\n\n    it('should return false if no directory/file is referenced by the sharing id', () => {\n      expect(\n        isThereFileReferencedBySharingId(queryResults, 'no-sharing-id')\n      ).toBeFalsy()\n    })\n\n    it('should return false if the reference id is not for io.cozy.sharings', () => {\n      expect(\n        isThereFileReferencedBySharingId(queryResults, 'other-type-id')\n      ).toBeFalsy()\n    })\n  })\n\n  describe('createSyncingFakeFile', () => {\n    it('should return null if no sharing value', () => {\n      expect(createSyncingFakeFile({})).toBeNull()\n    })\n\n    it('should return fake file well formated according to the sharing value', () => {\n      expect(\n        createSyncingFakeFile({\n          sharingValue: sharingsValue.id1\n        })\n      ).toMatchObject({\n        name: 'fileName.ext',\n        id: 'id1',\n        type: 'directory'\n      })\n    })\n  })\n\n  describe('checkSyncingFakeFileObsolescence', () => {\n    it('should return syncingFakeFile if there is no file with the same id', () => {\n      const syncingFakeFile = {\n        id: 'fakeFileId'\n      }\n\n      expect(\n        checkSyncingFakeFileObsolescence({\n          queryResults,\n          sharingId: 'id1',\n          sharingsValue,\n          setSharingsValue: jest.fn(),\n          syncingFakeFile\n        })\n      ).toMatchObject(syncingFakeFile)\n    })\n\n    it('should return null if there is a file with the same id', () => {\n      expect(\n        checkSyncingFakeFileObsolescence({\n          queryResults,\n          sharingId: 'file-with-sharing-id',\n          sharingsValue,\n          setSharingsValue: jest.fn()\n        })\n      ).toBeNull()\n    })\n  })\n\n  describe('computeSyncingFakeFile', () => {\n    it('should return null if no content', () => {\n      const { syncingFakeFile } = setupComputeSyncingFakeFile({ isEmpty: true })\n      expect(syncingFakeFile).toBeNull()\n    })\n\n    it('should return null if no sharing context', () => {\n      const { syncingFakeFile } = setupComputeSyncingFakeFile({\n        isSharingContextEmpty: true\n      })\n      expect(syncingFakeFile).toBeNull()\n    })\n\n    it('should return null if no syncingFakeFile created (for example because no sharing context found)', () => {\n      const { syncingFakeFile } = setupComputeSyncingFakeFile({\n        sharingId: 'no-id',\n        sharingsValue\n      })\n      expect(syncingFakeFile).toBeNull()\n    })\n\n    it('should return null if syncingFakeFile is no longer needed', () => {\n      const { syncingFakeFile } = setupComputeSyncingFakeFile({\n        sharingId: 'file-with-sharing-id',\n        sharingsValue,\n        queryResults\n      })\n      expect(syncingFakeFile).toBeNull()\n    })\n\n    it('should return syncingFakeFile if still needed', () => {\n      const { syncingFakeFile } = setupComputeSyncingFakeFile({\n        sharingId: 'id1',\n        sharingsValue,\n        queryResults\n      })\n      expect(syncingFakeFile).toMatchObject({\n        name: 'fileName.ext',\n        id: 'id1',\n        type: 'directory'\n      })\n    })\n  })\n\n  describe('isReferencedByShareInSharingContext', () => {\n    it('should return true or false if the file is referenced or not by a share in sharing context', () => {\n      const referencedFile = {\n        id: 'fileId',\n        relationships: {\n          referenced_by: {\n            data: [{ id: 'file-with-sharing-id', type: 'io.cozy.sharings' }]\n          }\n        }\n      }\n      const notReferencedFile = {\n        id: 'fileId',\n        relationships: {\n          referenced_by: {\n            data: [{ id: 'file-without-sharing-id', type: 'io.cozy.sharings' }]\n          }\n        }\n      }\n      const FileWithNoReference = {\n        id: 'fileId',\n        relationships: {\n          referenced_by: undefined\n        }\n      }\n\n      expect(\n        isReferencedByShareInSharingContext(referencedFile, sharingsValue)\n      ).toBeTruthy()\n      expect(\n        isReferencedByShareInSharingContext(notReferencedFile, sharingsValue)\n      ).toBeFalsy()\n      expect(\n        isReferencedByShareInSharingContext(FileWithNoReference, sharingsValue)\n      ).toBeFalsy()\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Folder/useSyncingFakeFile.js",
    "content": "import { useContext, useMemo } from 'react'\n\nimport { computeSyncingFakeFile } from './syncHelpers'\n\nimport AcceptingSharingContext from '@/lib/AcceptingSharingContext'\nimport { getSharingIdFromUrl } from '@/modules/navigation/duck'\n\nexport const useSyncingFakeFile = ({ isEmpty, queryResults }) => {\n  const { sharingsValue, setSharingsValue, fileValue } = useContext(\n    AcceptingSharingContext\n  )\n  const isSharingContextEmpty = useMemo(\n    () => Object.keys(sharingsValue).length <= 0,\n    [sharingsValue]\n  )\n  const sharingId = getSharingIdFromUrl(window.location)\n\n  const syncingFakeFile = computeSyncingFakeFile({\n    isEmpty,\n    isSharingContextEmpty,\n    queryResults,\n    sharingId,\n    sharingsValue,\n    setSharingsValue,\n    fileValue\n  })\n\n  return { syncingFakeFile }\n}\n"
  },
  {
    "path": "src/modules/views/Folder/virtualized/AddFolderWrapper.jsx",
    "content": "import React from 'react'\n\nimport { useVaultClient } from 'cozy-keys-lib'\nimport Table from 'cozy-ui/transpiled/react/Table'\nimport TableBody from 'cozy-ui/transpiled/react/TableBody'\nimport TableCell from 'cozy-ui/transpiled/react/TableCell'\nimport TableHead from 'cozy-ui/transpiled/react/TableHead'\nimport TableRow from 'cozy-ui/transpiled/react/TableRow'\n\nimport styles from '@/styles/folder-view.styl'\n\nimport { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'\nimport AddFolder from '@/modules/filelist/AddFolder'\n\nconst AddFolderWrapper = ({\n  columns,\n  currentFolderId,\n  refreshFolderContent,\n  driveId\n}) => {\n  const vaultClient = useVaultClient()\n  const { viewType } = useViewSwitcherContext()\n\n  if (viewType === 'grid') {\n    return (\n      <div className={styles['fil-folder-body-grid']}>\n        <AddFolder\n          vaultClient={vaultClient}\n          currentFolderId={currentFolderId}\n          refreshFolderContent={refreshFolderContent}\n          driveId={driveId}\n        />\n      </div>\n    )\n  }\n\n  return (\n    <Table>\n      <TableHead>\n        <TableRow>\n          {columns.map((column, idx) => (\n            <TableCell\n              key={idx}\n              className={column.id === 'name' ? 'u-pl-1' : ''}\n            >\n              {column.label}\n            </TableCell>\n          ))}\n        </TableRow>\n      </TableHead>\n      <TableBody>\n        <TableRow>\n          <TableCell className=\"u-pl-1\">\n            <AddFolder\n              vaultClient={vaultClient}\n              currentFolderId={currentFolderId}\n              refreshFolderContent={refreshFolderContent}\n              driveId={driveId}\n            />\n          </TableCell>\n          <TableCell>—</TableCell>\n          <TableCell>—</TableCell>\n          <TableCell>—</TableCell>\n        </TableRow>\n      </TableBody>\n    </Table>\n  )\n}\n\nexport default AddFolderWrapper\n"
  },
  {
    "path": "src/modules/views/Folder/virtualized/FolderViewBody.jsx",
    "content": "import React, { useState, useEffect, useMemo } from 'react'\nimport { useSelector } from 'react-redux'\n\nimport { useBreakpoints } from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport FolderViewBodyContent from './FolderViewBodyContent'\nimport { makeColumns } from '../helpers'\n\nimport { EmptyWrapper } from '@/components/Error/Empty'\nimport Oops from '@/components/Error/Oops'\nimport { useThumbnailSizeContext } from '@/lib/ThumbnailSizeContext'\nimport FileListRowsPlaceholder from '@/modules/filelist/FileListRowsPlaceholder'\nimport { isTypingNewFolderName } from '@/modules/filelist/duck'\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\nimport AddFolderWrapper from '@/modules/views/Folder/virtualized/AddFolderWrapper'\n\nconst FolderViewBody = ({\n  currentFolderId,\n  displayedFolder,\n  queryResults,\n  actions,\n  canUpload = true,\n  canDrag,\n  withFilePath = false,\n  refreshFolderContent = null,\n  driveId,\n  orderProps = {\n    sortOrder: {},\n    setOrder: () => {},\n    isSettingsLoaded: true\n  }\n}) => {\n  const { isDesktop } = useBreakpoints()\n  const IsAddingFolder = useSelector(isTypingNewFolderName)\n  const { isBigThumbnail } = useThumbnailSizeContext()\n  const { clearItems } = useNewItemHighlightContext()\n  const { sortOrder, setOrder, isSettingsLoaded } = orderProps\n\n  const isInError = queryResults.some(query => query.fetchStatus === 'failed')\n  const hasDataToShow =\n    !isInError &&\n    queryResults.some(query => query.data && query.data.length > 0)\n  const isLoading =\n    !hasDataToShow &&\n    queryResults.some(\n      query => query.fetchStatus === 'loading' && !query.lastUpdate\n    )\n  const isEmpty = !isInError && !isLoading && !hasDataToShow\n\n  const columns = useMemo(() => makeColumns(isBigThumbnail), [isBigThumbnail])\n\n  /**\n   *  Since we are not able to restore the scroll correctly,\n   * and force the scroll to top every time we change the\n   * current folder. This is to avoid this kind of weird\n   * behavior:\n   * - If I go to a sub-folder, if this subfolder has a lot\n   * of data and I scrolled down until the bottom. If I go\n   * back, then my folder will also be scrolled down.\n   *\n   * This is an ugly hack, yeah.\n   * */\n  useEffect(() => {\n    if (isDesktop) {\n      const scrollable = document.querySelectorAll(\n        '[data-testid=fil-content-body]'\n      )[0]\n      if (scrollable) {\n        scrollable.scroll({ top: 0 })\n      }\n    } else {\n      window.scroll({ top: 0 })\n    }\n\n    clearItems()\n  }, [currentFolderId, isDesktop, clearItems])\n\n  /**\n   * When we mount the component when we already have data in cache,\n   * the mount is time consuming since we'll render at least 100 lines\n   * of File.\n   *\n   * React seems to batch together the fact that :\n   * - we change a route\n   * - we want to render 100 files\n   * resulting in a non smooth transition between views (Drive / Recent / ...)\n   *\n   * In order to bypass this batch, we use a state to first display a much\n   * more simpler component and then the files\n   */\n  const [needsToWait, setNeedsToWait] = useState(true)\n  useEffect(() => {\n    let timeout = null\n    if (!isLoading) {\n      timeout = setTimeout(() => {\n        setNeedsToWait(false)\n      }, 50)\n    }\n    return () => clearTimeout(timeout)\n  }, [isLoading])\n\n  if (needsToWait || isLoading || !isSettingsLoaded) {\n    return <FileListRowsPlaceholder />\n  }\n\n  if (isInError) {\n    return <Oops />\n  }\n\n  /* TODO FolderViewBody should not have the responsability to chose\n      which empty component to display. It should be done by the \"view\" itself.\n      But adding a new prop like <FolderViewBody emptyComponent={}\n      is not good enought too */\n  if (isEmpty) {\n    if (IsAddingFolder) {\n      return (\n        <AddFolderWrapper\n          columns={columns}\n          currentFolderId={currentFolderId}\n          refreshFolderContent={refreshFolderContent}\n          driveId={driveId}\n        />\n      )\n    }\n\n    return (\n      <EmptyWrapper\n        currentFolderId={currentFolderId}\n        displayedFolder={displayedFolder}\n        canUpload={canUpload}\n        driveId={driveId}\n        refreshFolderContent={refreshFolderContent}\n      />\n    )\n  }\n\n  return (\n    <FolderViewBodyContent\n      currentFolderId={currentFolderId}\n      displayedFolder={displayedFolder}\n      actions={actions}\n      columns={columns}\n      queryResults={queryResults}\n      isEmpty={isEmpty}\n      canDrag={canDrag}\n      withFilePath={withFilePath}\n      driveId={driveId}\n      orderProps={{\n        sortOrder,\n        setOrder\n      }}\n      refreshFolderContent={refreshFolderContent}\n    />\n  )\n}\n\nexport default FolderViewBody\n"
  },
  {
    "path": "src/modules/views/Folder/virtualized/FolderViewBodyContent.jsx",
    "content": "import React, { useCallback, useMemo, useRef, useState } from 'react'\nimport { useSelector } from 'react-redux'\n\nimport { useClient } from 'cozy-client'\nimport flag from 'cozy-flags'\nimport { useSharingContext } from 'cozy-sharing'\nimport {\n  stableSort,\n  getComparator\n} from 'cozy-ui/transpiled/react/Table/Virtualized/helpers'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport Grid from './Grid'\nimport { secondarySort } from '../helpers'\nimport { useSyncingFakeFile } from '../useSyncingFakeFile'\n\nimport { SHARED_DRIVES_DIR_ID } from '@/constants/config'\nimport { useShiftSelection } from '@/hooks/useShiftSelection'\nimport { useViewSwitcherContext } from '@/lib/ViewSwitcherContext'\nimport { isTypingNewFolderName } from '@/modules/filelist/duck'\nimport { useCancelable } from '@/modules/move/hooks/useCancelable'\nimport RectangularSelection from '@/modules/selection/RectangularSelection'\nimport SelectionBar from '@/modules/selection/SelectionBar'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport Table from '@/modules/views/Folder/virtualized/Table'\nimport { makeRows, onDrop } from '@/modules/views/Folder/virtualized/helpers'\n\nconst FolderViewBodyContent = ({\n  currentFolderId,\n  displayedFolder,\n  actions,\n  columns,\n  queryResults,\n  isEmpty,\n  canDrag,\n  withFilePath,\n  driveId,\n  orderProps = {\n    sortOrder: {},\n    setOrder: () => {}\n  },\n  refreshFolderContent\n}) => {\n  const folderViewRef = useRef()\n  // Stores the actual scroll container HTMLElement from virtuoso's scrollerRef callback.\n  // This is needed because virtuosoRef.current only exposes the handle API (scrollTo, scrollBy, etc.),\n  // not the DOM element required for RectangularSelection's auto-scroll functionality.\n  const [scrollElement, setScrollElement] = useState(null)\n  // Ref to store the virtuoso imperative handle (scrollTo, scrollToIndex, etc.).\n  // Note: This is a plain ref and does not trigger re-renders on assignment.\n  // Consumers should access virtuosoRef.current when needed (e.g., in effects\n  // triggered by other state changes like highlightedItems).\n  const virtuosoRef = useRef(null)\n\n  const client = useClient()\n\n  const { selectAll, selectedItems, setSelectedItems, setIsSelectAll } =\n    useSelectionContext()\n  const { sharedPaths } = useSharingContext()\n  const { registerCancelable } = useCancelable()\n  const { showAlert } = useAlert()\n  const { viewType } = useViewSwitcherContext()\n  const { t } = useI18n()\n  const IsAddingFolder = useSelector(isTypingNewFolderName)\n  const { sortOrder } = orderProps\n  const { order, attribute: orderBy } = sortOrder\n\n  const fetchMore = queryResults.find(query => query.hasMore)?.fetchMore\n\n  const isSelectedItem = file => {\n    if (file._id === SHARED_DRIVES_DIR_ID) {\n      return false\n    }\n    return selectedItems.some(item => item._id === file._id)\n  }\n\n  const { syncingFakeFile } = useSyncingFakeFile({ isEmpty, queryResults })\n\n  const rows = useMemo(\n    () => makeRows({ queryResults, IsAddingFolder, syncingFakeFile }),\n    [queryResults, IsAddingFolder, syncingFakeFile]\n  )\n\n  const sortedRows = useMemo(() => {\n    const sortedData = stableSort(rows, getComparator(order, orderBy))\n    return secondarySort(sortedData)\n  }, [rows, order, orderBy])\n\n  const { setLastInteractedItem, onShiftClick } = useShiftSelection(\n    {\n      items: sortedRows,\n      viewType\n    },\n    folderViewRef\n  )\n\n  const onInteractWithFile = (itemId, event) => {\n    setLastInteractedItem(itemId)\n    onShiftClick(itemId, event)\n  }\n\n  const isDynamicSelectionEnabled = flag('drive.dynamic-selection.enabled')\n\n  const handleContainerClick = useCallback(\n    e => {\n      const target = e.target\n      const isOnFile = target.closest('[data-file-id]')\n      if (isOnFile) return\n\n      setSelectedItems({})\n      setIsSelectAll(false)\n    },\n    [setSelectedItems, setIsSelectAll]\n  )\n\n  const dragProps = useMemo(\n    () => ({\n      enabled: canDrag,\n      dragId: 'drag-drive',\n      onDrop: onDrop({\n        client,\n        showAlert,\n        selectAll,\n        registerCancelable,\n        sharedPaths,\n        t,\n        refreshFolderContent,\n        displayedFolder\n      })\n    }),\n    [\n      canDrag,\n      client,\n      showAlert,\n      selectAll,\n      registerCancelable,\n      sharedPaths,\n      t,\n      refreshFolderContent,\n      displayedFolder\n    ]\n  )\n\n  const tableComponent = (\n    <Table\n      rows={sortedRows}\n      columns={columns}\n      dragProps={dragProps}\n      fetchMore={fetchMore}\n      selectAll={selectAll}\n      isSelectedItem={isSelectedItem}\n      selectedItems={selectedItems}\n      currentFolderId={currentFolderId}\n      withFilePath={withFilePath}\n      actions={actions}\n      driveId={driveId}\n      ref={folderViewRef}\n      virtuosoRef={virtuosoRef}\n      scrollerRef={setScrollElement}\n      onInteractWithFile={onInteractWithFile}\n      orderProps={orderProps}\n      refreshFolderContent={refreshFolderContent}\n    />\n  )\n\n  const gridComponent = (\n    <Grid\n      items={sortedRows}\n      currentFolderId={currentFolderId}\n      withFilePath={withFilePath}\n      actions={actions}\n      fetchMore={fetchMore}\n      selectedItems={selectedItems}\n      isSelectedItem={isSelectedItem}\n      driveId={driveId}\n      dragProps={dragProps}\n      onInteractWithFile={onInteractWithFile}\n      ref={folderViewRef}\n      virtuosoRef={virtuosoRef}\n      scrollerRef={setScrollElement}\n      refreshFolderContent={refreshFolderContent}\n    />\n  )\n\n  const viewContent = viewType === 'list' ? tableComponent : gridComponent\n\n  return (\n    <div className=\"u-h-100 u-w-100\">\n      <SelectionBar actions={actions} />\n      {isDynamicSelectionEnabled ? (\n        <RectangularSelection\n          items={sortedRows}\n          scrollContainerRef={folderViewRef}\n          scrollElement={scrollElement}\n          onSelectEnd={setLastInteractedItem}\n        >\n          {viewContent}\n        </RectangularSelection>\n      ) : (\n        <div onClick={handleContainerClick} className=\"u-h-100\">\n          {viewContent}\n        </div>\n      )}\n    </div>\n  )\n}\n\nexport default FolderViewBodyContent\n"
  },
  {
    "path": "src/modules/views/Folder/virtualized/Grid.jsx",
    "content": "import cx from 'classnames'\nimport React, {\n  forwardRef,\n  useCallback,\n  useMemo,\n  useRef,\n  useState\n} from 'react'\n\nimport flag from 'cozy-flags'\nimport { useVaultClient } from 'cozy-keys-lib'\nimport VirtualizedGridList from 'cozy-ui/transpiled/react/GridList/Virtualized'\nimport virtuosoComponents from 'cozy-ui/transpiled/react/GridList/Virtualized/Dnd/virtuosoComponents'\nimport CustomDragLayer from 'cozy-ui/transpiled/react/utils/Dnd/CustomDrag/CustomDragLayer'\n\nimport GridWrapper from './GridWrapper'\n\nimport styles from '@/styles/filelist.styl'\n\nimport RightClickFileMenu from '@/components/RightClick/RightClickFileMenu'\nimport AddFolder from '@/modules/filelist/AddFolder'\nimport { GridFileWithSelection as GridFile } from '@/modules/filelist/virtualized/GridFile'\nimport useScrollToHighlightedItem from '@/modules/views/Folder/virtualized/useScrollToHighlightedItem'\n\nconst GridItem = forwardRef(({ item, context, ...props }, ref) => {\n  const { componentProps } = context\n  const DefaultItem = virtuosoComponents.Item\n\n  return (\n    <DefaultItem\n      ref={ref}\n      item={item}\n      context={context}\n      {...props}\n      {...componentProps?.Item}\n    />\n  )\n})\n\nGridItem.displayName = 'GridItem'\n\nconst GridItemMemo = React.memo(GridItem)\n\nconst mergedComponents = {\n  ...virtuosoComponents,\n  List: GridWrapper,\n  Item: GridItemMemo\n}\n\nconst Grid = forwardRef(\n  (\n    {\n      items,\n      actions,\n      withFilePath = false,\n      refreshFolderContent,\n      isSharingContextEmpty,\n      isSharingShortcut = null,\n      isReferencedByShareInSharingContext,\n      sharingsValue,\n      fetchMore,\n      dragProps,\n      currentFolderId,\n      driveId,\n      onInteractWithFile,\n      selectedItems,\n      isSelectedItem,\n      virtuosoRef: parentVirtuosoRef,\n      scrollerRef\n    },\n    ref\n  ) => {\n    const vaultClient = useVaultClient()\n    const internalVirtuosoRef = useRef(null)\n    const virtuosoRef = parentVirtuosoRef || internalVirtuosoRef\n    const [itemsInDropProcess, setItemsInDropProcess] = useState([])\n\n    const componentProps = useMemo(\n      () => ({\n        Item: {\n          className: cx(styles['fil-content-grid-item'])\n        }\n      }),\n      []\n    )\n\n    const itemRenderer = useCallback(\n      (file, { isOver }) => (\n        <>\n          {file.type != 'tempDirectory' ? (\n            <RightClickFileMenu key={file?._id} doc={file} actions={actions}>\n              <GridFile\n                key={file?._id}\n                attributes={file}\n                withSelectionCheckbox={!flag('drive.dynamic-selection.enabled')}\n                withFilePath={withFilePath}\n                actions={actions}\n                refreshFolderContent={refreshFolderContent}\n                isInSyncFromSharing={\n                  !isSharingContextEmpty &&\n                  isSharingShortcut?.(file) &&\n                  isReferencedByShareInSharingContext(file, sharingsValue)\n                }\n                isOver={isOver}\n                onInteractWithFile={onInteractWithFile}\n              />\n            </RightClickFileMenu>\n          ) : (\n            <AddFolder\n              vaultClient={vaultClient}\n              currentFolderId={currentFolderId}\n              refreshFolderContent={refreshFolderContent}\n              driveId={driveId}\n            />\n          )}\n        </>\n      ),\n      [\n        actions,\n        withFilePath,\n        refreshFolderContent,\n        isSharingContextEmpty,\n        isSharingShortcut,\n        isReferencedByShareInSharingContext,\n        sharingsValue,\n        onInteractWithFile,\n        vaultClient,\n        currentFolderId,\n        driveId\n      ]\n    )\n\n    const gridContext = useMemo(\n      () => ({\n        actions,\n        selectedItems,\n        isSelectedItem,\n        dragProps,\n        itemRenderer,\n        itemsInDropProcess,\n        componentProps,\n        setItemsInDropProcess,\n        items\n      }),\n      [\n        actions,\n        selectedItems,\n        isSelectedItem,\n        dragProps,\n        itemRenderer,\n        itemsInDropProcess,\n        componentProps,\n        items\n      ]\n    )\n\n    useScrollToHighlightedItem(virtuosoRef, items)\n\n    return (\n      <div\n        className=\"u-h-100\"\n        ref={ref}\n        tabIndex={0}\n        style={{ outline: 'none' }}\n      >\n        {dragProps?.dragId && <CustomDragLayer dragId={dragProps.dragId} />}\n        <VirtualizedGridList\n          ref={virtuosoRef}\n          scrollerRef={scrollerRef}\n          components={mergedComponents}\n          items={items}\n          endReached={fetchMore}\n          context={gridContext}\n        />\n      </div>\n    )\n  }\n)\n\nGrid.displayName = 'Grid'\n\nexport default React.memo(Grid)\n"
  },
  {
    "path": "src/modules/views/Folder/virtualized/GridWrapper.jsx",
    "content": "import cx from 'classnames'\nimport React, { forwardRef } from 'react'\n\nimport styles from '@/styles/folder-view.styl'\n\nconst GridWrapper = forwardRef(({ style, children }, ref) => (\n  <div ref={ref} className={cx(styles['fil-folder-body-grid'])} style={style}>\n    {children}\n  </div>\n))\n\nGridWrapper.displayName = 'GridWrapper'\n\nexport default GridWrapper\n"
  },
  {
    "path": "src/modules/views/Folder/virtualized/Table.jsx",
    "content": "import cx from 'classnames'\nimport React, {\n  forwardRef,\n  useCallback,\n  useMemo,\n  useRef,\n  useState\n} from 'react'\nimport { useSelector } from 'react-redux'\n\nimport flag from 'cozy-flags'\nimport VirtualizedTable from 'cozy-ui/transpiled/react/Table/Virtualized'\nimport TableRowDnD from 'cozy-ui/transpiled/react/Table/Virtualized/Dnd/TableRow'\nimport virtuosoComponentsDnd from 'cozy-ui/transpiled/react/Table/Virtualized/Dnd/virtuosoComponents'\nimport CustomDragLayer from 'cozy-ui/transpiled/react/utils/Dnd/CustomDrag/CustomDragLayer'\n\nimport { secondarySort } from '../helpers'\n\nimport styles from '@/styles/filelist.styl'\n\nimport RightClickFileMenu from '@/components/RightClick/RightClickFileMenu'\nimport { useClipboardContext } from '@/contexts/ClipboardProvider'\nimport { isRenaming as isRenamingSelector } from '@/modules/drive/rename'\nimport Cell from '@/modules/filelist/virtualized/cells/Cell'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\nimport useScrollToHighlightedItem from '@/modules/views/Folder/virtualized/useScrollToHighlightedItem'\n\nconst TableRow = forwardRef(({ item, context, children, ...props }, ref) => {\n  const { isItemCut } = useClipboardContext()\n  const isCut = isItemCut(item._id)\n  const { actions } = context\n\n  return (\n    <RightClickFileMenu doc={item} actions={actions} {...props}>\n      <TableRowDnD\n        ref={ref}\n        item={item}\n        context={context}\n        componentsProps={{ tableRow: { disabled: isCut } }}\n        data-file-id={item._id}\n      >\n        {children}\n      </TableRowDnD>\n    </RightClickFileMenu>\n  )\n})\n\nTableRow.displayName = 'TableRow'\n\nconst TableRowMemo = React.memo(TableRow)\n\nconst components = {\n  ...virtuosoComponentsDnd,\n  TableRow: TableRowMemo\n}\n\nconst Table = forwardRef(\n  (\n    {\n      rows,\n      columns,\n      dragProps,\n      selectAll,\n      fetchMore,\n      isSelectedItem,\n      selectedItems,\n      currentFolderId,\n      withFilePath,\n      actions,\n      driveId,\n      orderProps = {\n        sortOrder: {},\n        setOrder: () => {}\n      },\n      onInteractWithFile,\n      refreshFolderContent,\n      virtuosoRef: parentVirtuosoRef,\n      scrollerRef\n    },\n    ref\n  ) => {\n    const { toggleSelectedItem, setSelectedItems } = useSelectionContext()\n    const { isNew } = useNewItemHighlightContext()\n    const isRenamingActive = useSelector(isRenamingSelector)\n    const internalVirtuosoRef = useRef(null)\n    const virtuosoRef = parentVirtuosoRef || internalVirtuosoRef\n    const [itemsInDropProcess, setItemsInDropProcess] = useState([])\n\n    const { sortOrder, setOrder } = orderProps\n\n    const selectedItemsCount = Object.keys(selectedItems || {}).length\n    const handleRowSelect = useCallback(\n      (row, event) => {\n        if (isRenamingActive) return\n        event?.stopPropagation?.()\n        if (\n          flag('drive.dynamic-selection.enabled') &&\n          selectedItemsCount > 0 &&\n          !event?.shiftKey\n        ) {\n          setSelectedItems({ [row._id]: row })\n        } else {\n          toggleSelectedItem(row)\n        }\n        onInteractWithFile?.(row?._id, event)\n      },\n      [\n        toggleSelectedItem,\n        onInteractWithFile,\n        selectedItemsCount,\n        setSelectedItems,\n        isRenamingActive\n      ]\n    )\n\n    const handleSort = ({ order, orderBy }) => {\n      setOrder({\n        order,\n        attribute: orderBy\n      })\n    }\n\n    const tableContext = useMemo(\n      () => ({\n        actions,\n        selectedItems,\n        isSelectedItem,\n        dragProps,\n        itemsInDropProcess,\n        setItemsInDropProcess\n      }),\n      [actions, selectedItems, isSelectedItem, dragProps, itemsInDropProcess]\n    )\n\n    // Memoize componentsProps to avoid recreating the Cell component on every render\n    // This follows Virtuoso's recommendation to not define custom components inline\n    const componentsProps = useMemo(\n      () => ({\n        rowContent: {\n          onClick: handleRowSelect,\n          children: (\n            <Cell\n              currentFolderId={currentFolderId}\n              withFilePath={withFilePath}\n              actions={actions}\n              onInteractWithFile={onInteractWithFile}\n              refreshFolderContent={refreshFolderContent}\n              driveId={driveId}\n            />\n          )\n        }\n      }),\n      [\n        handleRowSelect,\n        currentFolderId,\n        withFilePath,\n        actions,\n        onInteractWithFile,\n        refreshFolderContent,\n        driveId\n      ]\n    )\n\n    useScrollToHighlightedItem(virtuosoRef, rows)\n\n    return (\n      <div\n        className={cx('u-h-100', 'u-pl-1', styles['fil-file-list-container'])}\n        ref={ref}\n        tabIndex={0}\n        style={{ outline: 'none' }}\n      >\n        {dragProps?.dragId && <CustomDragLayer dragId={dragProps.dragId} />}\n        <VirtualizedTable\n          ref={virtuosoRef}\n          scrollerRef={scrollerRef}\n          context={tableContext}\n          components={components}\n          rows={rows}\n          columns={columns}\n          withCheckbox={!flag('drive.dynamic-selection.enabled')}\n          endReached={fetchMore}\n          defaultOrder={{\n            direction: sortOrder.order,\n            by: sortOrder.attribute\n          }}\n          secondarySort={secondarySort}\n          onSelectAll={selectAll}\n          onSelect={handleRowSelect}\n          isSelectedItem={isSelectedItem}\n          isNewItem={isNew}\n          selectedItems={selectedItems}\n          increaseViewportBy={200}\n          onSortChange={handleSort}\n          componentsProps={componentsProps}\n        />\n      </div>\n    )\n  }\n)\n\nTable.displayName = 'Table'\n\nexport default React.memo(Table)\n"
  },
  {
    "path": "src/modules/views/Folder/virtualized/helpers.js",
    "content": "import logger from '@/lib/logger'\nimport { executeMove } from '@/modules/paste'\n\nexport const makeRows = ({ queryResults, IsAddingFolder, syncingFakeFile }) => {\n  const rows = queryResults.flatMap(el => el.data)\n  if (IsAddingFolder) {\n    rows.push({\n      type: 'tempDirectory'\n    })\n  }\n  if (syncingFakeFile) {\n    rows.push(syncingFakeFile)\n  }\n\n  return rows\n}\n\nexport const onDrop =\n  ({\n    client,\n    showAlert,\n    selectAll,\n    registerCancelable,\n    sharedPaths,\n    t,\n    refreshFolderContent,\n    displayedFolder\n  }) =>\n  async (draggedItems, itemHovered, selectedItems) => {\n    if (\n      itemHovered.type !== 'directory' ||\n      draggedItems.some(({ _id }) => _id === itemHovered._id)\n    ) {\n      return null\n    }\n\n    if (selectedItems.length > 0) {\n      selectAll([])\n    }\n\n    try {\n      await Promise.all(\n        draggedItems.map(async draggedItem => {\n          const force =\n            Array.isArray(sharedPaths) &&\n            !sharedPaths.includes(itemHovered.path)\n          await registerCancelable(\n            executeMove(\n              client,\n              draggedItem,\n              displayedFolder,\n              itemHovered,\n              force\n            )\n          )\n        })\n      )\n      showAlert({\n        severity: 'success',\n        message: t('Move.success', {\n          subject: draggedItems[0].name,\n          target: itemHovered.name,\n          smart_count: draggedItems.length\n        })\n      })\n      if (refreshFolderContent) {\n        refreshFolderContent()\n      }\n    } catch (error) {\n      logger.warn(`Error while dragging files:`, error)\n      showAlert({\n        severity: 'error',\n        message: t('Move.error', {\n          smart_count: draggedItems.length\n        })\n      })\n    }\n  }\n"
  },
  {
    "path": "src/modules/views/Folder/virtualized/useScrollToHighlightedItem.jsx",
    "content": "import { useEffect, useRef } from 'react'\n\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\n/**\n * Scrolls the virtualized list to the latest highlighted item present in the\n * current dataset. This ensures that newly created files/folders become\n * visible even when inserted outside the current viewport.\n *\n * @param {React.MutableRefObject} virtuosoRef - Ref to the Virtuoso component instance\n * @param {Array} items - Current array of items rendered by the list\n */\nconst useScrollToHighlightedItem = (virtuosoRef, items) => {\n  const { highlightedItems } = useNewItemHighlightContext()\n  const lastScrolledIdRef = useRef(null)\n\n  useEffect(() => {\n    if (!highlightedItems?.length) {\n      lastScrolledIdRef.current = null\n      return\n    }\n\n    if (!Array.isArray(items) || items.length === 0) {\n      return\n    }\n\n    const indexById = new Map()\n    for (const [index, current] of items.entries()) {\n      if (current?._id) {\n        indexById.set(current._id, index)\n      }\n    }\n\n    const targetItem = highlightedItems[highlightedItems.length - 1]\n\n    if (\n      !targetItem?._id ||\n      !indexById.has(targetItem._id) ||\n      targetItem._id === lastScrolledIdRef.current\n    )\n      return\n\n    const targetIndex = indexById.get(targetItem._id)\n    const virtuosoHandle = virtuosoRef?.current\n\n    if (targetIndex === -1 || !virtuosoHandle) {\n      return\n    }\n\n    virtuosoHandle.scrollToIndex({\n      index: targetIndex,\n      align: 'center',\n      behavior: 'smooth'\n    })\n\n    lastScrolledIdRef.current = targetItem._id\n  }, [highlightedItems, items, virtuosoRef])\n}\n\nexport default useScrollToHighlightedItem\n"
  },
  {
    "path": "src/modules/views/Folder/virtualized/useScrollToHighlightedItem.spec.jsx",
    "content": "import { renderHook, waitFor } from '@testing-library/react'\n\nimport flag from 'cozy-flags'\n\nimport useScrollToHighlightedItem from './useScrollToHighlightedItem'\n\nimport { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider'\n\njest.mock('cozy-flags', () => jest.fn())\njest.mock('@/modules/upload/NewItemHighlightProvider', () => ({\n  useNewItemHighlightContext: jest.fn()\n}))\n\ndescribe('useScrollToHighlightedItem', () => {\n  let highlightedItemsValue\n  let virtuosoRef\n\n  beforeEach(() => {\n    highlightedItemsValue = []\n    virtuosoRef = {\n      current: {\n        scrollToIndex: jest.fn()\n      }\n    }\n\n    flag.mockReturnValue(true)\n    useNewItemHighlightContext.mockImplementation(() => ({\n      highlightedItems: highlightedItemsValue\n    }))\n  })\n\n  afterEach(() => {\n    jest.clearAllMocks()\n  })\n\n  const setHighlightedItems = items => {\n    highlightedItemsValue = items\n    useNewItemHighlightContext.mockImplementation(() => ({\n      highlightedItems: highlightedItemsValue\n    }))\n  }\n\n  it('scrolls to the highlighted item present in the dataset', async () => {\n    setHighlightedItems([{ _id: 'match' }])\n    const items = [{ _id: 'foo' }, { _id: 'match' }, { _id: 'bar' }]\n\n    renderHook(() => useScrollToHighlightedItem(virtuosoRef, items))\n\n    await waitFor(() =>\n      expect(virtuosoRef.current.scrollToIndex).toHaveBeenCalledWith({\n        index: 1,\n        align: 'center',\n        behavior: 'smooth'\n      })\n    )\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Modal/DuplicateSharedDriveFilesView.jsx",
    "content": "import React from 'react'\nimport { useNavigate, useLocation, useParams } from 'react-router-dom'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport useDisplayedFolder from '@/hooks/useDisplayedFolder'\nimport { DuplicateModal } from '@/modules/duplicate/components/DuplicateModal'\nimport { useQueryMultipleSharedDriveFolders } from '@/modules/shareddrives/hooks/useQueryMultipleSharedDriveFolders'\n\nconst DuplicateSharedDriveFilesView = () => {\n  const navigate = useNavigate()\n  const { state } = useLocation()\n  const { driveId } = useParams()\n  const { displayedFolder } = useDisplayedFolder()\n\n  const { sharedDriveResults } = useQueryMultipleSharedDriveFolders({\n    folderIds: state?.fileIds ?? [],\n    driveId\n  })\n\n  if (sharedDriveResults && displayedFolder) {\n    const onClose = () => {\n      navigate('..', { replace: true })\n    }\n\n    const entries = sharedDriveResults.map(file => ({\n      ...file,\n      path: `${displayedFolder.path}/${file.name}`\n    }))\n\n    return (\n      <DuplicateModal\n        currentFolder={displayedFolder}\n        entries={entries}\n        onClose={onClose}\n        showSharedDriveFolder={true}\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nexport { DuplicateSharedDriveFilesView }\n"
  },
  {
    "path": "src/modules/views/Modal/MoveFilesView.jsx",
    "content": "import React from 'react'\nimport { Navigate, useLocation, useNavigate } from 'react-router-dom'\n\nimport { hasQueryBeenLoaded, useQuery } from 'cozy-client'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport useDisplayedFolder from '@/hooks/useDisplayedFolder'\nimport MoveModal from '@/modules/move/MoveModal'\nimport { useSharedDrives } from '@/modules/shareddrives/hooks/useSharedDrives'\nimport { buildParentsByIdsQuery } from '@/queries'\n\nconst MoveFilesView = ({ isOpenInViewer }) => {\n  const navigate = useNavigate()\n  const { state } = useLocation()\n  const { displayedFolder } = useDisplayedFolder()\n  const { sharedDrives } = useSharedDrives()\n\n  const hasFileIds = state?.fileIds != undefined\n\n  const fileQuery = buildParentsByIdsQuery(hasFileIds ? state.fileIds : [])\n  const fileResult = useQuery(fileQuery.definition, {\n    ...fileQuery.options,\n    enabled: hasFileIds\n  })\n\n  if (!hasFileIds) {\n    return <Navigate to=\"..\" replace={true} />\n  }\n\n  if (hasQueryBeenLoaded(fileResult) && fileResult.data && displayedFolder) {\n    const onClose = () => {\n      navigate('..', { replace: true })\n    }\n\n    const onMovingSuccess = () => {\n      /**\n       * In file viewer, after moving success the file will not exist in the current folder,\n       * we should navigate to current folder view instead.\n       * */\n      navigate(isOpenInViewer ? '../..' : '..', { replace: true })\n    }\n\n    const showNextcloudFolder = !fileResult.data.some(\n      file => file.type === 'directory'\n    )\n\n    return (\n      <MoveModal\n        currentFolder={displayedFolder}\n        entries={fileResult.data}\n        onClose={onClose}\n        onMovingSuccess={onMovingSuccess}\n        showNextcloudFolder={showNextcloudFolder}\n        showSharedDriveFolder={sharedDrives?.length > 0}\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nexport { MoveFilesView }\n"
  },
  {
    "path": "src/modules/views/Modal/MovePublicFilesView.tsx",
    "content": "import React from 'react'\nimport { Navigate, useLocation, useNavigate } from 'react-router-dom'\n\nimport usePublicFileByIdsQuery from '../Public/usePublicFileByIdsQuery'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport useDisplayedFolder from '@/hooks/useDisplayedFolder'\nimport MoveModal from '@/modules/move/MoveModal'\n\ninterface LocationState {\n  fileIds?: string[]\n}\n\nconst MovePublicFilesView = (): React.ReactElement => {\n  const navigate = useNavigate()\n  const { state } = useLocation() as { state: LocationState | null }\n  const { displayedFolder } = useDisplayedFolder()\n\n  const hasFileIds = state?.fileIds !== undefined\n\n  const { files, fetchStatus } = usePublicFileByIdsQuery(\n    state?.fileIds ?? ([] as string[])\n  )\n\n  if (!hasFileIds) {\n    return <Navigate to=\"..\" replace={true} />\n  }\n\n  if (fetchStatus === 'loaded' && files.length && displayedFolder) {\n    const onClose = (): void => {\n      navigate('..', { replace: true })\n    }\n\n    const onMovingSuccess = (): void => {\n      navigate('..', { replace: true, state: { refresh: true } })\n    }\n\n    return (\n      <MoveModal\n        currentFolder={displayedFolder}\n        entries={files}\n        onClose={onClose}\n        onMovingSuccess={onMovingSuccess}\n        isPublic\n        showNextcloudFolder={false}\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nexport { MovePublicFilesView }\n"
  },
  {
    "path": "src/modules/views/Modal/MoveSharedDriveFilesView.jsx",
    "content": "import React from 'react'\nimport { useNavigate, useLocation } from 'react-router-dom'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport useDisplayedFolder from '@/hooks/useDisplayedFolder'\nimport MoveModal from '@/modules/move/MoveModal'\nimport { useQueryMultipleSharedDriveFolders } from '@/modules/shareddrives/hooks/useQueryMultipleSharedDriveFolders'\n\nconst MoveSharedDriveFilesView = () => {\n  const navigate = useNavigate()\n  const { state } = useLocation()\n  const { displayedFolder } = useDisplayedFolder()\n\n  const { sharedDriveResults } = useQueryMultipleSharedDriveFolders({\n    folderIds: state.fileIds,\n    driveId: displayedFolder?.driveId\n  })\n\n  if (sharedDriveResults && displayedFolder) {\n    const onClose = () => {\n      navigate('..', { replace: true })\n    }\n\n    const showNextcloudFolder = !sharedDriveResults.some(\n      file => file.type === 'directory'\n    )\n\n    const entries = sharedDriveResults.map(file => ({\n      ...file,\n      path: `${displayedFolder.path}/${file.name}`\n    }))\n\n    return (\n      <MoveModal\n        currentFolder={displayedFolder}\n        entries={entries}\n        onClose={onClose}\n        showNextcloudFolder={showNextcloudFolder}\n        showSharedDriveFolder={true}\n        driveId={displayedFolder.driveId}\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nexport { MoveSharedDriveFilesView }\n"
  },
  {
    "path": "src/modules/views/Modal/QualifyFileView.jsx",
    "content": "import React from 'react'\nimport { useNavigate, useParams } from 'react-router-dom'\n\nimport { isQueryLoading, useClient, useQuery } from 'cozy-client'\nimport { getQualification } from 'cozy-client/dist/models/document'\nimport { themesList } from 'cozy-client/dist/models/document/documentTypeData'\nimport { isQualificationNote } from 'cozy-client/dist/models/document/documentTypeDataHelpers'\nimport { getBoundT } from 'cozy-client/dist/models/document/locales'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport FileDuotoneIcon from 'cozy-ui/transpiled/react/Icons/FileDuotone'\nimport FileTypeNoteIcon from 'cozy-ui/transpiled/react/Icons/FileTypeNote'\nimport NestedSelectResponsive from 'cozy-ui/transpiled/react/NestedSelect/NestedSelectResponsive'\nimport { useI18n } from 'twake-i18n'\n\nimport IconStack from '@/components/IconStack'\nimport { LoaderModal } from '@/components/LoaderModal'\nimport { buildFileOrFolderByIdQuery } from '@/queries'\n\nconst OptionIconStack = ({ icon }) => {\n  return (\n    <IconStack\n      backgroundIcon={<Icon icon={FileDuotoneIcon} color=\"#E049BF\" size={32} />}\n      {...(icon && {\n        foregroundIcon: <Icon icon={icon} color=\"#E049BF\" size={16} />\n      })}\n    />\n  )\n}\n\nconst makeOptions = ({ t, scannerT, focusedId }) => {\n  const themesWithNone = [\n    {\n      id: 'none',\n      items: [],\n      label: t('Scan.none')\n    },\n    themesList\n  ]\n  return {\n    focusedId,\n    children: themesWithNone.map(theme => {\n      return {\n        id: theme.label,\n        title:\n          theme.id === 'none'\n            ? t('Scan.none')\n            : scannerT(`Scan.themes.${theme.label}`),\n        icon: <OptionIconStack icon={theme.icon} />,\n        children: theme.items.map(item => {\n          return {\n            id: item.label,\n            item,\n            title: scannerT(`Scan.items.${item.label}`),\n            icon: isQualificationNote(item) ? (\n              <Icon icon={FileTypeNoteIcon} size={64} />\n            ) : (\n              <OptionIconStack icon={item.icon} />\n            )\n          }\n        })\n      }\n    })\n  }\n}\n\nexport const QualifyFileView = () => {\n  const navigate = useNavigate()\n  const { fileId } = useParams()\n  const { t, lang } = useI18n()\n  const client = useClient()\n  const scannerT = getBoundT(lang || 'en')\n\n  const fileQuery = buildFileOrFolderByIdQuery(fileId)\n  const { data: file, ...fileQueryResult } = useQuery(\n    fileQuery.definition,\n    fileQuery.options\n  )\n  const qualificationLabel = getQualification(file)?.label\n  const defaultOptions = makeOptions({\n    t,\n    scannerT,\n    focusedId: qualificationLabel\n  })\n\n  const onClose = () => {\n    navigate('..', { replace: true })\n  }\n\n  const handleClick = async ({ title, item }) => {\n    const fileCollection = client.collection('io.cozy.files')\n    const removeQualification = qualificationLabel && title === t('Scan.none')\n\n    if (!qualificationLabel && removeQualification) {\n      return onClose()\n    }\n\n    /*\n      In the case where we remove the qualification it's necessary to define the attribute to `null` and not `undefined`, with `undefined` the stack does not return the attribute and today the Redux store is not updated for a missing attribute.\n      As a result, the UI is not updated and continues to display the qualification on the document, even though it has been deleted in CouchDB.\n    */\n    await fileCollection.updateMetadataAttribute(file._id, {\n      qualification: removeQualification ? null : item\n    })\n    onClose()\n  }\n\n  const isSelected = ({ title, item }) => {\n    return qualificationLabel\n      ? qualificationLabel === item?.label\n      : title === t('Scan.none')\n  }\n\n  if (isQueryLoading(fileQueryResult)) {\n    return <LoaderModal />\n  }\n\n  return (\n    <NestedSelectResponsive\n      title={file.name}\n      options={defaultOptions}\n      noDivider\n      document={file}\n      onSelect={handleClick}\n      isSelected={isSelected}\n      onClose={onClose}\n    />\n  )\n}\n"
  },
  {
    "path": "src/modules/views/Modal/ShareDisplayedFolderView.jsx",
    "content": "import React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport flag from 'cozy-flags'\nimport { ShareModal } from 'cozy-sharing'\n\nimport { SHARING_TAB_DRIVES } from '@/constants/config'\nimport { useDisplayedFolder } from '@/hooks'\n\nconst ShareDisplayedFolderView = () => {\n  const { displayedFolder } = useDisplayedFolder()\n  const navigate = useNavigate()\n\n  if (displayedFolder) {\n    const onClose = () => {\n      navigate('..', { replace: true })\n    }\n\n    const onRevokeSuccess = () => {\n      if (displayedFolder.driveId) {\n        navigate(`/sharings?tab=${SHARING_TAB_DRIVES}`)\n      }\n    }\n\n    return (\n      <ShareModal\n        document={displayedFolder}\n        documentType=\"Files\"\n        sharingDesc={displayedFolder.name}\n        onClose={onClose}\n        onRevokeSuccess={onRevokeSuccess}\n        autoOpenShareRestriction={flag('sharing.auto-open-settings.enabled')}\n        showGenerateLinkButton={flag('sharing.generate-link-button.enabled')}\n      />\n    )\n  }\n\n  return null\n}\n\nexport { ShareDisplayedFolderView }\n"
  },
  {
    "path": "src/modules/views/Modal/ShareFileView.jsx",
    "content": "import React from 'react'\nimport { useNavigate, useParams } from 'react-router-dom'\n\nimport { hasQueryBeenLoaded, useQuery } from 'cozy-client'\nimport flag from 'cozy-flags'\nimport { ShareModal } from 'cozy-sharing'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport {\n  buildFileOrFolderByIdQuery,\n  buildSharedDriveFileOrFolderByIdQuery\n} from '@/queries'\n\nconst ShareFileView = () => {\n  const navigate = useNavigate()\n  const { fileId, driveId } = useParams()\n\n  const fileQuery = driveId\n    ? buildSharedDriveFileOrFolderByIdQuery({ fileId, driveId })\n    : buildFileOrFolderByIdQuery(fileId)\n  const fileResult = useQuery(fileQuery.definition, fileQuery.options)\n\n  const handleExit = () => {\n    navigate('..', { replace: true })\n  }\n\n  if (hasQueryBeenLoaded(fileResult) && fileResult.data) {\n    return (\n      <ShareModal\n        document={fileResult.data}\n        driveId={driveId}\n        documentType=\"Files\"\n        sharingDesc={fileResult.data.name}\n        onClose={handleExit}\n        autoOpenShareRestriction={flag('sharing.auto-open-settings.enabled')}\n        showGenerateLinkButton={flag('sharing.generate-link-button.enabled')}\n      />\n    )\n  }\n\n  // After successfully removing self from a shared file, the file is not found anymore but the query is considered loaded\n  // We check if the data is null, meaning the sharing has been removed\n  if (hasQueryBeenLoaded(fileResult) && !fileResult.data) {\n    handleExit()\n  }\n\n  // Accessing the URL of a file that doesn't exist anymore (or never existed)\n  // e.g. /folder/io.cozy.files.shared-with-me-dir/file/someidresolvingto404/share\n  if (fileResult.fetchStatus === 'failed') {\n    handleExit()\n  }\n\n  return <LoaderModal />\n}\n\nexport { ShareFileView }\n"
  },
  {
    "path": "src/modules/views/Nextcloud/NextcloudDeleteView.jsx",
    "content": "import React from 'react'\nimport {\n  Navigate,\n  useLocation,\n  useNavigate,\n  useSearchParams\n} from 'react-router-dom'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport { getParentPath } from '@/lib/path'\nimport { NextcloudDeleteConfirm } from '@/modules/nextcloud/components/NextcloudDeleteConfirm'\nimport { useNextcloudEntries } from '@/modules/nextcloud/hooks/useNextcloudEntries'\n\nconst NextcloudDeleteView = () => {\n  const navigate = useNavigate()\n  const [searchParams] = useSearchParams()\n  const { pathname } = useLocation()\n  const { entries, hasEntries, isLoading } = useNextcloudEntries()\n\n  const newPath = getParentPath(pathname) + `?${searchParams.toString()}`\n\n  const handleClose = () => {\n    navigate(newPath, { replace: true })\n  }\n\n  if (!hasEntries) {\n    return <Navigate to={newPath} replace />\n  }\n\n  if (entries && !isLoading) {\n    return <NextcloudDeleteConfirm files={entries} onClose={handleClose} />\n  }\n\n  return <LoaderModal />\n}\n\nexport { NextcloudDeleteView }\n"
  },
  {
    "path": "src/modules/views/Nextcloud/NextcloudDestroyView.tsx",
    "content": "import React, { FC, useCallback } from 'react'\nimport {\n  Navigate,\n  useLocation,\n  useParams,\n  useNavigate,\n  useSearchParams\n} from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport { NextcloudFile } from 'cozy-client/types/types'\n\ninterface NextcloudFilesCollection {\n  deletePermanently: (file: NextcloudFile) => Promise<void>\n}\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport { getParentPath } from '@/lib/path'\nimport { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'\nimport { useNextcloudEntries } from '@/modules/nextcloud/hooks/useNextcloudEntries'\nimport { useNextcloudPath } from '@/modules/nextcloud/hooks/useNextcloudPath'\nimport DestroyConfirm from '@/modules/trash/components/DestroyConfirm'\n\nconst NextcloudDestroyView: FC = () => {\n  const navigate = useNavigate()\n  const [searchParams] = useSearchParams()\n  const { sourceAccount } = useParams()\n  const client = useClient()\n  const path = useNextcloudPath({\n    insideTrash: true\n  })\n  const { pathname } = useLocation()\n  const { hasEntries, entries, isLoading } = useNextcloudEntries({\n    insideTrash: true\n  })\n\n  const newPath = `${getParentPath(pathname) ?? ''}?${searchParams.toString()}`\n\n  const handleClose = (): void => {\n    navigate(newPath, { replace: true })\n  }\n\n  const handleConfirm = useCallback(async (): Promise<void> => {\n    if (entries) {\n      for (const file of entries) {\n        const collection = client?.collection(\n          'io.cozy.remote.nextcloud.files'\n        ) as unknown as NextcloudFilesCollection\n        await collection.deletePermanently(file)\n      }\n      const queryId =\n        computeNextcloudFolderQueryId({\n          sourceAccount,\n          path\n        }) + '/trashed'\n      void client?.resetQuery(queryId)\n    }\n  }, [client, entries, path, sourceAccount])\n\n  if (!hasEntries) {\n    return <Navigate to={newPath} replace />\n  }\n\n  if (entries && !isLoading) {\n    return (\n      <DestroyConfirm\n        onConfirm={handleConfirm}\n        files={entries}\n        onClose={handleClose}\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nexport { NextcloudDestroyView }\n"
  },
  {
    "path": "src/modules/views/Nextcloud/NextcloudDuplicateView.tsx",
    "content": "import React, { FC } from 'react'\nimport {\n  Navigate,\n  useLocation,\n  useNavigate,\n  useSearchParams\n} from 'react-router-dom'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport { getParentPath } from '@/lib/path'\nimport { DuplicateModal } from '@/modules/duplicate/components/DuplicateModal'\nimport { useNextcloudCurrentFolder } from '@/modules/nextcloud/hooks/useNextcloudCurrentFolder'\nimport { useNextcloudEntries } from '@/modules/nextcloud/hooks/useNextcloudEntries'\n\nconst NextcloudDuplicateView: FC = () => {\n  const { pathname } = useLocation()\n  const [searchParams] = useSearchParams()\n  const navigate = useNavigate()\n\n  const currentFolder = useNextcloudCurrentFolder()\n  const { entries, hasEntries, isLoading } = useNextcloudEntries()\n\n  const newPath =\n    (getParentPath(pathname) ?? '') + `?${searchParams.toString()}`\n\n  if (!hasEntries && !isLoading) {\n    return <Navigate to={newPath} replace />\n  }\n\n  if (entries && currentFolder) {\n    const onClose = (): void => {\n      navigate(newPath, { replace: true })\n    }\n\n    return (\n      <DuplicateModal\n        showNextcloudFolder\n        currentFolder={currentFolder}\n        entries={entries}\n        onClose={onClose}\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nexport { NextcloudDuplicateView }\n"
  },
  {
    "path": "src/modules/views/Nextcloud/NextcloudFolderView.jsx",
    "content": "import React from 'react'\nimport { Outlet, useParams } from 'react-router-dom'\n\nimport { Content } from 'cozy-ui/transpiled/react/Layout'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { NextcloudBanner } from '@/modules/nextcloud/components/NextcloudBanner'\nimport { NextcloudBreadcrumb } from '@/modules/nextcloud/components/NextcloudBreadcrumb'\nimport { NextcloudFolderBody } from '@/modules/nextcloud/components/NextcloudFolderBody'\nimport { NextcloudToolbar } from '@/modules/nextcloud/components/NextcloudToolbar'\nimport { useNextcloudFolder } from '@/modules/nextcloud/hooks/useNextcloudFolder'\nimport { useNextcloudPath } from '@/modules/nextcloud/hooks/useNextcloudPath'\nimport FolderView from '@/modules/views/Folder/FolderView'\nimport FolderViewHeader from '@/modules/views/Folder/FolderViewHeader'\n\nconst NextcloudFolderView = () => {\n  const { isMobile } = useBreakpoints()\n\n  const { sourceAccount } = useParams()\n  const path = useNextcloudPath()\n  const { t } = useI18n()\n\n  const { nextcloudResult } = useNextcloudFolder({\n    sourceAccount,\n    path\n  })\n\n  var queryResults = [nextcloudResult]\n  if (path === '/') {\n    queryResults = [\n      nextcloudResult,\n      {\n        id: 'io.cozy.remote.nextcloud.files.trash-dir',\n        fetchStatus: nextcloudResult.fetchStatus,\n        data:\n          nextcloudResult.fetchStatus === 'loaded'\n            ? [\n                {\n                  _id: 'io.cozy.remote.nextcloud.files.trash-dir',\n                  type: 'directory',\n                  name: t('NextcloudBreadcrumb.trash')\n                }\n              ]\n            : []\n      }\n    ]\n  }\n\n  return (\n    <FolderView>\n      <Content className={isMobile ? '' : 'u-pt-1'}>\n        <FolderViewHeader>\n          <NextcloudBreadcrumb sourceAccount={sourceAccount} path={path} />\n          <NextcloudToolbar />\n        </FolderViewHeader>\n        <NextcloudBanner />\n        <NextcloudFolderBody path={path} queryResults={queryResults} />\n        <Outlet />\n      </Content>\n    </FolderView>\n  )\n}\n\nexport { NextcloudFolderView }\n"
  },
  {
    "path": "src/modules/views/Nextcloud/NextcloudMoveView.jsx",
    "content": "import React from 'react'\nimport {\n  Navigate,\n  useLocation,\n  useNavigate,\n  useSearchParams\n} from 'react-router-dom'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport { getParentPath } from '@/lib/path'\nimport MoveModal from '@/modules/move/MoveModal'\nimport { useNextcloudCurrentFolder } from '@/modules/nextcloud/hooks/useNextcloudCurrentFolder'\nimport { useNextcloudEntries } from '@/modules/nextcloud/hooks/useNextcloudEntries'\n\nconst NextcloudMoveView = () => {\n  const { pathname } = useLocation()\n  const [searchParams] = useSearchParams()\n  const navigate = useNavigate()\n\n  const currentFolder = useNextcloudCurrentFolder()\n  const { entries, hasEntries, isLoading } = useNextcloudEntries()\n\n  const newPath = getParentPath(pathname) + `?${searchParams.toString()}`\n\n  const onClose = () => {\n    navigate(newPath, { replace: true })\n  }\n\n  if (!hasEntries) {\n    return <Navigate to={newPath} replace />\n  }\n\n  if (entries && !isLoading && currentFolder) {\n    return (\n      <MoveModal\n        showNextcloudFolder\n        currentFolder={currentFolder}\n        entries={entries}\n        onClose={onClose}\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nexport { NextcloudMoveView }\n"
  },
  {
    "path": "src/modules/views/Nextcloud/NextcloudTrashEmptyView.tsx",
    "content": "import React, { FC } from 'react'\nimport {\n  useNavigate,\n  useParams,\n  useLocation,\n  useSearchParams\n} from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\n\ninterface NextcloudFilesCollection {\n  emptyTrash: (sourceAccount: string | undefined) => Promise<void>\n}\n\nimport { getParentPath } from '@/lib/path'\nimport { computeNextcloudFolderQueryId } from '@/modules/nextcloud/helpers'\nimport { useNextcloudPath } from '@/modules/nextcloud/hooks/useNextcloudPath'\nimport { EmptyTrashConfirm } from '@/modules/trash/components/EmptyTrashConfirm'\n\nconst NextcloudTrashEmptyView: FC = () => {\n  const { sourceAccount } = useParams()\n  const [searchParams] = useSearchParams()\n  const { pathname } = useLocation()\n  const path = useNextcloudPath({\n    insideTrash: true\n  })\n  const navigate = useNavigate()\n  const client = useClient()\n\n  const handleClose = (): void => {\n    navigate(`${getParentPath(pathname) ?? ''}?${searchParams.toString()}`, {\n      replace: true\n    })\n  }\n\n  const handleConfirm = async (): Promise<void> => {\n    const collection = client?.collection(\n      'io.cozy.remote.nextcloud.files'\n    ) as unknown as NextcloudFilesCollection\n    await collection.emptyTrash(sourceAccount)\n    const queryId =\n      computeNextcloudFolderQueryId({\n        sourceAccount,\n        path\n      }) + '/trashed'\n    await client?.resetQuery(queryId)\n  }\n\n  return <EmptyTrashConfirm onConfirm={handleConfirm} onClose={handleClose} />\n}\n\nexport { NextcloudTrashEmptyView }\n"
  },
  {
    "path": "src/modules/views/Nextcloud/NextcloudTrashView.tsx",
    "content": "import React, { FC } from 'react'\nimport { Outlet, useParams } from 'react-router-dom'\n\nimport { NextcloudBanner } from '@/modules/nextcloud/components/NextcloudBanner'\nimport { NextcloudBreadcrumb } from '@/modules/nextcloud/components/NextcloudBreadcrumb'\nimport { NextcloudTrashFolderBody } from '@/modules/nextcloud/components/NextcloudTrashFolderBody'\nimport { useNextcloudFolder } from '@/modules/nextcloud/hooks/useNextcloudFolder'\nimport { useNextcloudPath } from '@/modules/nextcloud/hooks/useNextcloudPath'\nimport { TrashToolbar } from '@/modules/trash/components/TrashToolbar'\nimport FolderView from '@/modules/views/Folder/FolderView'\nimport FolderViewHeader from '@/modules/views/Folder/FolderViewHeader'\n\nconst NextcloudTrashView: FC = () => {\n  const { sourceAccount } = useParams()\n  const path = useNextcloudPath({\n    insideTrash: true\n  })\n\n  const { nextcloudResult } = useNextcloudFolder({\n    sourceAccount,\n    path,\n    insideTrash: true\n  })\n\n  return (\n    <FolderView isNotFound={false}>\n      <FolderViewHeader>\n        <NextcloudBreadcrumb sourceAccount={sourceAccount} path={path} />\n        <TrashToolbar />\n      </FolderViewHeader>\n      <NextcloudBanner />\n      <NextcloudTrashFolderBody path={path} queryResults={[nextcloudResult]} />\n      <Outlet />\n    </FolderView>\n  )\n}\n\nexport { NextcloudTrashView }\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Create.jsx",
    "content": "import React from 'react'\nimport { useNavigate, useParams, Navigate } from 'react-router-dom'\n\nimport Dialog, { DialogContent } from 'cozy-ui/transpiled/react/Dialog'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\n\nimport Oops from '@/components/Error/Oops'\nimport {\n  canWriteOfficeDocument,\n  makeOnlyOfficeFileRoute\n} from '@/modules/views/OnlyOffice/helpers'\nimport useCreateFile from '@/modules/views/OnlyOffice/useCreateFile'\n\nconst Create = ({ isPublic = false }) => {\n  const navigate = useNavigate()\n  const { folderId, fileClass, driveId = undefined } = useParams()\n  const { status, fileId } = useCreateFile(folderId, fileClass, driveId)\n  const folderPath = `/folder/${folderId}`\n\n  if (!canWriteOfficeDocument()) {\n    return (\n      <Navigate\n        to={\n          driveId\n            ? `/onlyoffice/${driveId}/${folderId}/paywall`\n            : `${folderPath}/paywall`\n        }\n      />\n    )\n  }\n\n  if (status === 'error') {\n    return <Oops />\n  }\n\n  if (status === 'loaded' && fileId) {\n    const url = makeOnlyOfficeFileRoute(fileId, {\n      fromCreate: true,\n      fromPathname: driveId\n        ? `/shareddrive/${driveId}/${folderId}`\n        : folderPath,\n      fromPublicFolder: isPublic,\n      driveId\n    })\n    return navigate(url, {\n      replace: true\n    })\n  }\n\n  return (\n    <Dialog open={true} fullScreen transitionDuration={0}>\n      <DialogContent className=\"u-flex u-flex-column u-flex-items-center u-flex-justify-center\">\n        <>\n          <Spinner\n            size=\"xxlarge\"\n            middle={true}\n            loadingType=\"onlyOfficeCreateInProgress\"\n          />\n        </>\n      </DialogContent>\n    </Dialog>\n  )\n}\n\nexport default React.memo(Create)\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Editor.jsx",
    "content": "import React from 'react'\n\nimport { isIOS } from 'cozy-device-helper'\nimport flag from 'cozy-flags'\nimport { DialogContent } from 'cozy-ui/transpiled/react/Dialog'\nimport ViewerProvider from 'cozy-viewer/dist/providers/ViewerProvider'\n\nimport Error from '@/modules/views/OnlyOffice/Error'\nimport Loading from '@/modules/views/OnlyOffice/Loading'\nimport { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\nimport Title from '@/modules/views/OnlyOffice/Title'\nimport View from '@/modules/views/OnlyOffice/View'\nimport { FileDeletedModal } from '@/modules/views/OnlyOffice/components/FileDeletedModal'\nimport { FileDivergedModal } from '@/modules/views/OnlyOffice/components/FileDivergedModal'\nimport {\n  DEFAULT_EDITOR_TOOLBAR_HEIGHT_IOS,\n  DEFAULT_EDITOR_TOOLBAR_HEIGHT\n} from '@/modules/views/OnlyOffice/config'\nimport useConfig from '@/modules/views/OnlyOffice/useConfig'\n\nconst getEditorToolbarHeight = editorToolbarHeightFlag => {\n  if (Number.isInteger(editorToolbarHeightFlag)) {\n    return editorToolbarHeightFlag\n  } else if (isIOS()) {\n    return DEFAULT_EDITOR_TOOLBAR_HEIGHT_IOS\n  } else {\n    return DEFAULT_EDITOR_TOOLBAR_HEIGHT\n  }\n}\n\nexport const Editor = () => {\n  const { config, status } = useConfig()\n  const {\n    isEditorModeView,\n    hasFileDiverged,\n    hasFileDeleted,\n    file,\n    isReadOnly,\n    isPublic\n  } = useOnlyOfficeContext()\n\n  if (status === 'error') return <Error />\n  if (status !== 'loaded' || !config) return <Loading />\n\n  const { serverUrl, apiUrl, docEditorConfig } = config\n\n  const editorToolbarHeight = getEditorToolbarHeight(\n    flag('drive.onlyoffice.editorToolbarHeight')\n  )\n  return (\n    <ViewerProvider file={file} isPublic={isPublic} isReadOnly={isReadOnly}>\n      <Title />\n      <DialogContent\n        style={\n          isEditorModeView\n            ? {\n                marginTop: `-${editorToolbarHeight}px`\n              }\n            : undefined\n        }\n        className=\"u-flex u-flex-column u-p-0\"\n      >\n        <View\n          id={new URL(serverUrl).hostname}\n          apiUrl={apiUrl}\n          docEditorConfig={docEditorConfig}\n        />\n        {hasFileDiverged ? <FileDivergedModal /> : null}\n        {hasFileDeleted ? <FileDeletedModal /> : null}\n      </DialogContent>\n    </ViewerProvider>\n  )\n}\n\nexport default Editor\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Editor.spec.jsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient, useQuery } from 'cozy-client'\nimport useFetchJSON from 'cozy-client/dist/hooks/useFetchJSON'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport AppLike from 'test/components/AppLike'\nimport { officeDocParam } from 'test/data'\n\nimport Editor from '@/modules/views/OnlyOffice/Editor'\nimport { OnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\nimport {\n  isOfficeEnabled,\n  isOfficeEditingEnabled\n} from '@/modules/views/OnlyOffice/helpers'\n\njest.mock('cozy-client/dist/hooks/useFetchJSON', () => ({\n  __esModule: true,\n  default: jest.fn(),\n  useFetchJSON: jest.fn()\n}))\n\njest.mock('modules/views/OnlyOffice/helpers', () => ({\n  ...jest.requireActual('modules/views/OnlyOffice/helpers'),\n  isOfficeEnabled: jest.fn(),\n  isOfficeEditingEnabled: jest.fn()\n}))\n\njest.mock('cozy-ui/transpiled/react/providers/Breakpoints', () => ({\n  ...jest.requireActual('cozy-ui/transpiled/react/providers/Breakpoints'),\n  __esModule: true,\n  default: jest.fn(),\n  useBreakpoints: jest.fn()\n}))\n\njest.mock('cozy-client/dist/hooks/useQuery', () => jest.fn())\n\njest.mock('cozy-flags')\njest.mock('modules/views/OnlyOffice/Toolbar', () => () => <div>Toolbar</div>)\njest.mock('cozy-viewer', () => ({\n  ...jest.requireActual('cozy-viewer'),\n  __esModule: true,\n  default: () => <div data-testid=\"ViewerForTest\" />\n}))\n\nconst client = createMockClient({})\nclient.plugins = {\n  realtime: {\n    subscribe: () => {},\n    unsubscribe: () => {}\n  }\n}\n\nconst setup = ({\n  isMobile = false,\n  isEditorModeView = true,\n  isReadOnly = false,\n  isPublic = false,\n  isTrashed = false\n} = {}) => {\n  useBreakpoints.mockReturnValue({ isMobile })\n  const root = render(\n    <AppLike\n      client={client}\n      routerContextValue={{\n        router: { location: { pathname: '/onlyoffice/fileId' } }\n      }}\n      sharingContextValue={{\n        byDocId: {},\n        documentType: 'Files'\n      }}\n    >\n      <OnlyOfficeContext.Provider\n        value={{\n          fileId: '123',\n          isPublic,\n          isReadOnly,\n          isEditorReady: true,\n          isEditorModeView,\n          officeKey: '321',\n          isTrashed,\n          setOfficeKey: jest.fn()\n        }}\n      >\n        <Editor />\n      </OnlyOfficeContext.Provider>\n    </AppLike>\n  )\n\n  return { root }\n}\n\ndescribe('Editor', () => {\n  afterEach(() => {\n    document.body.innerHTML = '' // used to reset document.getElementById(id) present in View\n  })\n\n  it('should not show the title but a spinner if the doc is not loaded', () => {\n    useFetchJSON.mockReturnValue({ fetchStatus: 'loading', data: undefined })\n\n    const { root } = setup()\n    const { queryByTestId } = root\n\n    expect(queryByTestId('onlyoffice-content-spinner')).toBeTruthy()\n    expect(queryByTestId('onlyoffice-title')).toBeFalsy()\n  })\n\n  it('should not show the title but the CozyUi Viewer instead if stack returns an error', async () => {\n    useFetchJSON.mockReturnValue({ fetchStatus: 'error', data: undefined })\n    useQuery.mockReturnValue(officeDocParam)\n\n    const { root } = setup()\n    const { queryByTestId } = root\n\n    expect(queryByTestId('onlyoffice-content-spinner')).toBeFalsy()\n    expect(queryByTestId('onlyoffice-title')).toBeFalsy()\n    expect(queryByTestId('ViewerForTest')).toBeTruthy()\n  })\n\n  it('should show the title and the container view if the only office server is installed', () => {\n    useFetchJSON.mockReturnValue({\n      fetchStatus: 'loaded',\n      data: officeDocParam\n    })\n    useQuery.mockReturnValue(officeDocParam)\n    isOfficeEnabled.mockReturnValue(true)\n\n    const { root } = setup()\n    const { container, queryByTestId } = root\n\n    expect(queryByTestId('onlyoffice-content-spinner')).toBeFalsy()\n    expect(queryByTestId('onlyoffice-title')).toBeTruthy()\n    expect(container.querySelector('#onlyOfficeEditor')).toBeTruthy()\n  })\n\n  it('should show the CozyUi Viewer if the only office server is not installed', () => {\n    useFetchJSON.mockReturnValue({\n      fetchStatus: 'loaded',\n      data: officeDocParam\n    })\n    useQuery.mockReturnValue(officeDocParam)\n    isOfficeEnabled.mockReturnValue(false)\n\n    const { root } = setup()\n    const { queryByTestId } = root\n\n    expect(queryByTestId('onlyoffice-content-spinner')).toBeFalsy()\n    expect(queryByTestId('onlyoffice-title')).toBeFalsy()\n    expect(queryByTestId('ViewerForTest')).toBeTruthy()\n  })\n\n  it('should show trashed banner when when the file has been deleted', () => {\n    useFetchJSON.mockReturnValue({\n      fetchStatus: 'loaded',\n      data: officeDocParam\n    })\n    useQuery.mockReturnValue(officeDocParam)\n    isOfficeEnabled.mockReturnValue(true)\n\n    setup({ isMobile: false, isTrashed: true })\n\n    expect(screen.queryByTestId('onlyoffice-title')).toBeTruthy()\n    expect(screen.queryByText('The item is in your trash')).toBeInTheDocument()\n  })\n\n  describe('Title', () => {\n    describe('on mobile', () => {\n      it('should hide title when when the editor is in edit mode', () => {\n        useFetchJSON.mockReturnValue({\n          fetchStatus: 'loaded',\n          data: officeDocParam\n        })\n        useQuery.mockReturnValue(officeDocParam)\n        isOfficeEnabled.mockReturnValue(true)\n\n        const { root } = setup({\n          isMobile: true,\n          isEditorModeView: false\n        })\n        const { queryByTestId } = root\n\n        expect(queryByTestId('onlyoffice-title')).toBeFalsy()\n      })\n\n      it('should show title when when the editor is in view mode', () => {\n        useFetchJSON.mockReturnValue({\n          fetchStatus: 'loaded',\n          data: officeDocParam\n        })\n        useQuery.mockReturnValue(officeDocParam)\n        isOfficeEnabled.mockReturnValue(true)\n\n        const { root } = setup({ isMobile: true })\n        const { queryByTestId } = root\n\n        expect(queryByTestId('onlyoffice-title')).toBeTruthy()\n      })\n    })\n\n    describe('on desktop', () => {\n      it('should show title when when the editor is in edit mode', () => {\n        useFetchJSON.mockReturnValue({\n          fetchStatus: 'loaded',\n          data: officeDocParam\n        })\n        useQuery.mockReturnValue(officeDocParam)\n        isOfficeEnabled.mockReturnValue(true)\n\n        const { root } = setup({\n          isMobile: false,\n          isEditorModeView: false\n        })\n        const { queryByTestId } = root\n\n        expect(queryByTestId('onlyoffice-title')).toBeTruthy()\n      })\n\n      it('should show title when when the editor is in view mode', () => {\n        useFetchJSON.mockReturnValue({\n          fetchStatus: 'loaded',\n          data: officeDocParam\n        })\n        useQuery.mockReturnValue(officeDocParam)\n        isOfficeEnabled.mockReturnValue(true)\n\n        const { root } = setup({ isMobile: false })\n        const { queryByTestId } = root\n\n        expect(queryByTestId('onlyoffice-title')).toBeTruthy()\n      })\n    })\n  })\n\n  describe('ReadOnlyFab', () => {\n    describe('on mobile', () => {\n      it('should show the readOnlyFab', () => {\n        useFetchJSON.mockReturnValue({\n          fetchStatus: 'loaded',\n          data: officeDocParam\n        })\n        useQuery.mockReturnValue(officeDocParam)\n        isOfficeEditingEnabled.mockReturnValue(true)\n\n        setup({ isMobile: true })\n\n        expect(screen.queryByLabelText('Edit')).toBeTruthy()\n      })\n\n      it('should show the readOnlyFab', () => {\n        useFetchJSON.mockReturnValue({\n          fetchStatus: 'loaded',\n          data: officeDocParam\n        })\n        useQuery.mockReturnValue(officeDocParam)\n        isOfficeEditingEnabled.mockReturnValue(true)\n\n        setup({ isMobile: true })\n\n        expect(screen.queryByLabelText('Edit')).toBeTruthy()\n      })\n    })\n\n    describe('on desktop', () => {\n      it('should show the readOnlyFab when the editor is in view mode', () => {\n        useFetchJSON.mockReturnValue({\n          fetchStatus: 'loaded',\n          data: officeDocParam\n        })\n        useQuery.mockReturnValue(officeDocParam)\n        isOfficeEditingEnabled.mockReturnValue(true)\n\n        setup({ isMobile: false })\n\n        expect(screen.queryByText('Edit')).toBeNull()\n      })\n\n      it('should hide the readOnlyFab when the editor is in edit mode', () => {\n        useFetchJSON.mockReturnValue({\n          fetchStatus: 'loaded',\n          data: officeDocParam\n        })\n        useQuery.mockReturnValue(officeDocParam)\n        isOfficeEditingEnabled.mockReturnValue(true)\n\n        setup({ isMobile: false, isEditorModeView: false })\n\n        expect(screen.queryByText('Edit')).toBeFalsy()\n      })\n\n      it('should hide the readOnlyFab when the document is in read only', () => {\n        useFetchJSON.mockReturnValue({\n          fetchStatus: 'loaded',\n          data: officeDocParam\n        })\n        useQuery.mockReturnValue(officeDocParam)\n        isOfficeEditingEnabled.mockReturnValue(true)\n\n        setup({\n          isMobile: false,\n          isReadOnly: true\n        })\n\n        expect(screen.queryByLabelText('Edit')).toBeFalsy()\n      })\n\n      it('should hide the readOnlyFab when the document is trashed', () => {\n        useFetchJSON.mockReturnValue({\n          fetchStatus: 'loaded',\n          data: officeDocParam\n        })\n        useQuery.mockReturnValue(officeDocParam)\n        isOfficeEditingEnabled.mockReturnValue(true)\n\n        setup({ isMobile: false, isTrashed: true })\n\n        expect(screen.queryByText('Edit')).toBeFalsy()\n      })\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Error.jsx",
    "content": "import React, { useMemo } from 'react'\nimport { RemoveScroll } from 'react-remove-scroll'\n\nimport { isQueryLoading, useQuery } from 'cozy-client'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\nimport Viewer, {\n  FooterActionButtons,\n  ForwardOrDownloadButton,\n  ToolbarButtons,\n  SharingButton\n} from 'cozy-viewer'\nimport { useI18n } from 'twake-i18n'\n\nimport Oops from '@/components/Error/Oops'\nimport { useRedirectLink } from '@/hooks/useRedirectLink'\nimport { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\nimport { buildFileOrFolderByIdQuery } from '@/queries'\n\nconst Error = () => {\n  const { t } = useI18n()\n  const { fileId, isPublic } = useOnlyOfficeContext()\n  const { redirectBack } = useRedirectLink({ isPublic })\n\n  const fileQuery = useMemo(() => buildFileOrFolderByIdQuery(fileId), [fileId])\n  const fileResult = useQuery(fileQuery.definition, fileQuery.options)\n  const files = useMemo(() => [fileResult.data], [fileResult])\n\n  if (isQueryLoading(fileResult)) {\n    return (\n      <Spinner\n        className=\"u-flex u-flex-items-center u-flex-justify-center u-flex-grow-1\"\n        size=\"xxlarge\"\n      />\n    )\n  }\n\n  if (fileResult.fetchStatus === 'failed') {\n    return <Oops title={t('error.open_file')} />\n  }\n\n  return (\n    <RemoveScroll>\n      <Viewer files={files} currentIndex={0} onCloseRequest={redirectBack}>\n        <ToolbarButtons>\n          <SharingButton variant=\"iconButton\" />\n        </ToolbarButtons>\n        <FooterActionButtons>\n          <SharingButton />\n          <ForwardOrDownloadButton variant=\"buttonIcon\" />\n        </FooterActionButtons>\n      </Viewer>\n    </RemoveScroll>\n  )\n}\n\nexport default React.memo(Error)\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Loading.jsx",
    "content": "import React from 'react'\n\nimport { DialogContent } from 'cozy-ui/transpiled/react/Dialog'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\n\nconst Loading = () => {\n  return (\n    <DialogContent className=\"u-flex u-flex-items-center u-flex-justify-center\">\n      <span data-testid=\"onlyoffice-content-spinner\">\n        <Spinner size=\"xxlarge\" />\n      </span>\n    </DialogContent>\n  )\n}\n\nexport default Loading\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/OnlyOfficeAIAssistantPanel.tsx",
    "content": "import React from 'react'\n\nimport AIAssistantPanel from 'cozy-viewer/dist/Panel/AI/AIAssistantPanel'\nimport { withViewerLocales } from 'cozy-viewer/dist/hoc/withViewerLocales'\nimport { useViewer } from 'cozy-viewer/dist/providers/ViewerProvider'\n\nimport styles from './styles.styl'\n\nconst OnlyOfficeAIAssistantPanel: React.FC = () => {\n  const { isOpenAiAssistant } = useViewer()\n\n  return (\n    <>\n      {isOpenAiAssistant ? (\n        <div className={styles['ai-assistant-panel']}>\n          <AIAssistantPanel />\n        </div>\n      ) : null}\n    </>\n  )\n}\n\nexport default withViewerLocales(OnlyOfficeAIAssistantPanel)\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/OnlyOfficePaywallView.jsx",
    "content": "import React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport flag from 'cozy-flags'\nimport { OnlyOfficePaywall } from 'cozy-ui-plus/dist/Paywall'\n\nconst OnlyOfficePaywallView = ({ isPublic = false }) => {\n  const navigate = useNavigate()\n\n  const onClose = () => {\n    navigate('..')\n  }\n\n  return (\n    <OnlyOfficePaywall\n      isPublic={isPublic}\n      isIapEnabled={flag('flagship.iap.enabled')}\n      onClose={onClose}\n    />\n  )\n}\n\nexport default OnlyOfficePaywallView\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/OnlyOfficeProvider.jsx",
    "content": "import React, {\n  createContext,\n  useState,\n  useMemo,\n  useEffect,\n  useContext,\n  useCallback\n} from 'react'\nimport { useSearchParams } from 'react-router-dom'\n\nimport { useClient, useQuery } from 'cozy-client'\nimport { useSharingContext } from 'cozy-sharing'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { officeDefaultMode } from '@/modules/views/OnlyOffice/helpers'\nimport { buildFileOrFolderByIdQuery } from '@/queries'\n\nconst OnlyOfficeContext = createContext()\n\nconst OnlyOfficeProvider = ({\n  fileId,\n  driveId,\n  isPublic,\n  isReadOnly,\n  isFromSharing,\n  username,\n  isInSharedFolder,\n  children\n}) => {\n  const client = useClient()\n  const { isDesktop, isMobile } = useBreakpoints()\n  const [searchParam] = useSearchParams()\n  const { hasWriteAccess } = useSharingContext()\n  const [isEditorReady, setIsEditorReady] = useState(false)\n  const [editorMode, setEditorMode] = useState(\n    officeDefaultMode(isDesktop, isMobile)\n  )\n\n  const [hasFileDiverged, setFileDiverged] = useState(false)\n  const [hasFileDeleted, setFileDeleted] = useState(false)\n  const [officeKey, setOfficeKey] = useState(null)\n  const [isTrashed, setTrashed] = useState(false)\n  const [hasBeenEdited, setHasBeenEdited] = useState(editorMode === 'edit')\n\n  const isEditorModeView = useMemo(() => editorMode === 'view', [editorMode])\n\n  const fileQuery = buildFileOrFolderByIdQuery(fileId)\n  const fileResult = useQuery(fileQuery.definition, fileQuery.options)\n\n  const handleFileUpdated = useCallback(\n    // eslint-disable-next-line react-hooks/preserve-manual-memoization\n    data => {\n      /**\n       * To determine whether a file has diverged between its version on the cozy-stack and its version on the onlyoffice server, we use 2 criteria:\n       * - That its content has changed with md5sum\n       * - That this modification was not made by the onlyoffice server\n       */\n      if (\n        fileResult?.data?.md5sum !== data.md5sum &&\n        data.cozyMetadata.uploadedBy.slug !== 'onlyoffice-server' &&\n        hasBeenEdited\n      ) {\n        setFileDiverged(true)\n      }\n      if (data.trashed && hasBeenEdited) {\n        setFileDeleted(true)\n      }\n    },\n    [fileResult?.data?.md5sum, hasBeenEdited]\n  )\n\n  useEffect(() => {\n    const realtime = client.plugins.realtime\n    realtime.subscribe('updated', 'io.cozy.files', fileId, handleFileUpdated)\n    return () => {\n      realtime.unsubscribe(\n        'updated',\n        'io.cozy.files',\n        fileId,\n        handleFileUpdated\n      )\n    }\n  }, [client, fileId, handleFileUpdated])\n\n  useEffect(() => {\n    if (!hasWriteAccess(fileId, driveId)) return\n\n    if (\n      isEditorModeView ||\n      searchParam.get('fromCreate') === 'true' ||\n      searchParam.get('fromEdit') === 'true'\n    ) {\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setEditorMode('edit')\n    }\n  }, [searchParam, fileId, driveId, hasWriteAccess, isEditorModeView])\n\n  useEffect(() => {\n    if (fileResult.data?.trashed) {\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setTrashed(true)\n      setEditorMode('view')\n    } else {\n      setTrashed(false)\n    }\n  }, [fileResult])\n\n  useEffect(() => {\n    if (editorMode === 'edit') {\n      // eslint-disable-next-line react-hooks/set-state-in-effect\n      setHasBeenEdited(true)\n    }\n  }, [editorMode])\n\n  return (\n    <OnlyOfficeContext.Provider\n      value={{\n        fileId,\n        driveId,\n        hasFileDiverged,\n        setFileDiverged,\n        hasFileDeleted,\n        setFileDeleted,\n        isPublic,\n        isReadOnly,\n        isFromSharing,\n        username,\n        isInSharedFolder,\n        isEditorReady,\n        setIsEditorReady,\n        editorMode,\n        setEditorMode,\n        isEditorModeView,\n        setOfficeKey,\n        officeKey,\n        isTrashed,\n        file: fileResult?.data\n      }}\n    >\n      {children}\n    </OnlyOfficeContext.Provider>\n  )\n}\n\nconst useOnlyOfficeContext = () => useContext(OnlyOfficeContext)\n\nexport { OnlyOfficeContext, OnlyOfficeProvider, useOnlyOfficeContext }\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/ReadOnlyFab.jsx",
    "content": "import React, { useCallback } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport Fab from 'cozy-ui/transpiled/react/Fab'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport CheckIcon from 'cozy-ui/transpiled/react/Icons/Check'\nimport RenameIcon from 'cozy-ui/transpiled/react/Icons/Rename'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport { useFabStyles } from '@/modules/drive/helpers'\nimport { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\nimport { canWriteOfficeDocument } from '@/modules/views/OnlyOffice/helpers'\n\nconst ReadOnlyFab = () => {\n  const navigate = useNavigate()\n  const { t } = useI18n()\n  const { isMobile } = useBreakpoints()\n\n  const { isEditorModeView, editorMode, setEditorMode } = useOnlyOfficeContext()\n\n  const handleClick = useCallback(() => {\n    if (canWriteOfficeDocument()) {\n      setEditorMode(editorMode === 'view' ? 'edit' : 'view')\n    } else {\n      navigate('./paywall')\n    }\n  }, [editorMode, setEditorMode, navigate])\n\n  const label = isEditorModeView\n    ? t('OnlyOffice.actions.edit')\n    : t('OnlyOffice.actions.validate')\n\n  const fabProps = isMobile ? { 'aria-label': label } : { variant: 'extended' }\n\n  const styles = useFabStyles({\n    right: isMobile ? '1rem' : '30px',\n    bottom: isMobile ? '1rem' : '55px'\n  })\n\n  return (\n    <Fab\n      className={styles.root}\n      color=\"primary\"\n      onClick={handleClick}\n      {...fabProps}\n    >\n      <Icon\n        icon={isEditorModeView ? RenameIcon : CheckIcon}\n        className={!isMobile ? 'u-mr-half' : ''}\n        aria-hidden=\"true\"\n      />\n      {!isMobile && label}\n    </Fab>\n  )\n}\n\nexport default React.memo(ReadOnlyFab)\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Title.jsx",
    "content": "import React from 'react'\nimport { useSearchParams } from 'react-router-dom'\n\nimport { SharingBannerPlugin, OpenSharingLinkFabButton } from 'cozy-sharing'\nimport { useSharingInfos } from 'cozy-sharing'\nimport { DialogTitle } from 'cozy-ui/transpiled/react/Dialog'\nimport Divider from 'cozy-ui/transpiled/react/Divider'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { makeStyles } from 'cozy-ui/transpiled/react/styles'\n\nimport { TrashedBanner } from '@/components/TrashedBanner'\nimport { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\nimport Toolbar from '@/modules/views/OnlyOffice/Toolbar'\n\nconst useStyles = makeStyles(theme => ({\n  root: {\n    width: '100%',\n    height: '3rem',\n    backgroundColor: theme.palette.background.paper\n  }\n}))\n\nconst Title = () => {\n  const [searchParams] = useSearchParams(window.location.search)\n  const { isMobile } = useBreakpoints()\n  const { fileId, isPublic, isEditorModeView, isTrashed } =\n    useOnlyOfficeContext()\n  const sharingInfos = useSharingInfos()\n  const { loading, isSharingShortcutCreated } = sharingInfos\n  const styles = useStyles()\n\n  // Check if the sharing shortcut has already been created (but not synced)\n  const isShareAlreadyAdded = !loading && isSharingShortcutCreated\n  // Check if you are sharing Cozy to Cozy (Link sharing is on the `/public` route)\n  const isCozyToCozySharing = window.location.pathname === '/preview'\n  // Check if you are sharing Cozy to Cozy synced (Also on the `/public` route)\n  const isCozyToCozySharingSynced = searchParams.has('username')\n  // Show the sharing banner plugin only on shared links view and cozy to cozy sharing view(not added)\n  const isSharingBannerPluginDisplayed =\n    isPublic &&\n    (!isShareAlreadyAdded || !isCozyToCozySharing) &&\n    !isCozyToCozySharingSynced\n\n  const showDialogToolbar = isEditorModeView || !isMobile\n\n  const isAddToMyCozyFabDisplayed =\n    isMobile && isCozyToCozySharing && !isShareAlreadyAdded\n\n  return (\n    <div style={{ zIndex: 'var(--zIndex-nav)' }}>\n      {showDialogToolbar && (\n        <>\n          <DialogTitle\n            data-testid=\"onlyoffice-title\"\n            disableTypography\n            className=\"u-ellipsis u-flex u-flex-items-center u-p-0 u-pr-1\"\n            classes={styles}\n          >\n            <Toolbar sharingInfos={sharingInfos} />\n          </DialogTitle>\n          <Divider />\n        </>\n      )}\n      {isTrashed ? (\n        <div style={{ backgroundColor: 'var(--paperBackgroundColor)' }}>\n          <TrashedBanner fileId={fileId} isPublic={isPublic} />\n        </div>\n      ) : isSharingBannerPluginDisplayed ? (\n        <SharingBannerPlugin />\n      ) : null}\n      {isAddToMyCozyFabDisplayed && (\n        <OpenSharingLinkFabButton link={sharingInfos.addSharingLink} />\n      )}\n    </div>\n  )\n}\n\nexport default React.memo(Title)\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/BackButton.jsx",
    "content": "import React from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport PreviousIcon from 'cozy-ui/transpiled/react/Icons/Previous'\nimport { useI18n } from 'twake-i18n'\n\nconst BackButton = ({ onClick }) => {\n  // TODO: remove u-ml-half-s when https://github.com/cozy/cozy-ui/issues/1808 is fixed\n  const { t } = useI18n()\n\n  return (\n    <IconButton\n      data-testid=\"onlyoffice-backButton\"\n      className=\"u-ml-half-s\"\n      onClick={onClick}\n      size=\"medium\"\n      aria-label={t('button.back')}\n    >\n      <Icon icon={PreviousIcon} />\n    </IconButton>\n  )\n}\n\nexport default React.memo(BackButton)\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/EditButton.jsx",
    "content": "import React, { useState } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useClient, useQuery, Q, isQueryLoading } from 'cozy-client'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport LightbulbIcon from 'cozy-ui/transpiled/react/Icons/Lightbulb'\nimport RenameIcon from 'cozy-ui/transpiled/react/Icons/Rename'\nimport Tooltip from 'cozy-ui/transpiled/react/Tooltip'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport { makeStyles } from 'cozy-ui/transpiled/react/styles'\nimport { useI18n } from 'twake-i18n'\n\nimport { DOCTYPE_FILES_SETTINGS } from '@/lib/doctypes'\nimport { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\nimport { canWriteOfficeDocument } from '@/modules/views/OnlyOffice/helpers'\nimport { getAppSettingQuery } from '@/queries'\n\nconst useStyle = makeStyles({\n  popper: {\n    pointerEvents: 'auto',\n    '& .actions-tooltip': {\n      display: 'flex',\n      justifyContent: 'flex-end',\n      marginTop: '0.5rem',\n      '& > button': {\n        color: 'var(--white)'\n      }\n    }\n  },\n  tooltip: {\n    backgroundColor: 'var(--primaryColor)'\n  },\n  arrow: {\n    color: 'var(--primaryColor)'\n  }\n})\n\nconst EditButton = ({ openTooltip }) => {\n  const classes = useStyle()\n  const client = useClient()\n  const { t } = useI18n()\n  const { editorMode, setEditorMode } = useOnlyOfficeContext()\n  const navigate = useNavigate()\n  const [showTooltip, setShowTooltip] = useState(true)\n\n  const handleClick = () => {\n    if (canWriteOfficeDocument()) {\n      setEditorMode(editorMode === 'view' ? 'edit' : 'view')\n    } else {\n      navigate('./paywall')\n    }\n  }\n\n  const closeTooltip = () => {\n    setShowTooltip(false)\n  }\n\n  const handleTooltip = async () => {\n    const { data } = await client.query(Q(DOCTYPE_FILES_SETTINGS))\n    const settings = data?.[0] || {}\n    await client.save({\n      ...settings,\n      _type: DOCTYPE_FILES_SETTINGS,\n      hideOOEditTooltip: true\n    })\n  }\n\n  return (\n    <Tooltip\n      open={showTooltip && openTooltip}\n      classes={classes}\n      title={\n        <>\n          <div className=\"u-flex u-flex-items-center u-mb-half\">\n            <Icon icon={LightbulbIcon} className=\"u-mr-half\" />\n            <Typography variant=\"h6\" color=\"inherit\">\n              {t('OnlyOffice.tooltip.title')}\n            </Typography>\n          </div>\n          <Typography variant=\"body2\" color=\"inherit\">\n            {t('OnlyOffice.tooltip.text')}\n          </Typography>\n          <div className=\"actions-tooltip\">\n            <Buttons\n              onClick={handleTooltip}\n              variant=\"text\"\n              label={t('OnlyOffice.tooltip.actions.hide')}\n            />\n            <Buttons\n              onClick={closeTooltip}\n              variant=\"text\"\n              label={t('OnlyOffice.tooltip.actions.ok')}\n            />\n          </div>\n        </>\n      }\n    >\n      <Buttons\n        className=\"u-ml-half\"\n        onClick={handleClick}\n        disabled={editorMode === 'edit'}\n        startIcon={<Icon icon={RenameIcon} />}\n        label={t('OnlyOffice.actions.edit')}\n      />\n    </Tooltip>\n  )\n}\n\nconst EditButtonWithQuery = () => {\n  const { isEditorModeView } = useOnlyOfficeContext()\n\n  const { data: settings, ...appSettingsQueryResult } = useQuery(\n    getAppSettingQuery.definition,\n    getAppSettingQuery.options\n  )\n  const hideOOEditTooltip = settings?.[0]?.hideOOEditTooltip\n  const openTooltip = isQueryLoading(appSettingsQueryResult)\n    ? false\n    : !hideOOEditTooltip && isEditorModeView && canWriteOfficeDocument()\n\n  return <EditButton openTooltip={openTooltip} />\n}\n\nconst EditButtonWrapper = () => {\n  const { isPublic } = useOnlyOfficeContext()\n\n  if (isPublic) {\n    return <EditButton openTooltip={false} />\n  }\n\n  return <EditButtonWithQuery />\n}\n\nexport default EditButtonWrapper\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/FileIcon.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React from 'react'\n\nimport Icon from 'cozy-ui/transpiled/react/Icon'\n\nimport { makeOnlyOfficeIconByClass } from '@/modules/views/OnlyOffice/helpers'\n\nconst FileIcon = ({ fileClass }) => {\n  return (\n    <Icon\n      className=\"u-ml-half\"\n      icon={makeOnlyOfficeIconByClass(fileClass)}\n      size={32}\n    />\n  )\n}\n\nFileIcon.propTypes = {\n  fileClass: PropTypes.string.isRequired\n}\n\nexport default FileIcon\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/FileName.jsx",
    "content": "import cx from 'classnames'\nimport PropTypes from 'prop-types'\nimport React, { useState, useCallback } from 'react'\nimport { Link } from 'react-router-dom'\n\nimport MidEllipsis from 'cozy-ui/transpiled/react/MidEllipsis'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { makeStyles } from 'cozy-ui/transpiled/react/styles'\n\nimport styles from './styles.styl'\n\nimport filelistStyles from '@/styles/filelist.styl'\n\nimport { RenameInput } from '@/modules/drive/RenameInput'\nimport { makeParentFolderPath } from '@/modules/filelist/helpers'\nimport { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\n\nconst useStyles = makeStyles(theme => ({\n  name: {\n    margin: '1px 0 3px 1px'\n  },\n  renamable: {\n    '&:hover': {\n      margin: '0 0 2px',\n      border: `1px solid ${theme.palette.text.secondary}`,\n      borderRadius: '2px',\n      cursor: 'text'\n    }\n  }\n}))\n\nconst FileName = ({ file, isPublic }) => {\n  const muiStyles = useStyles()\n  const { isMobile } = useBreakpoints()\n  const { isReadOnly } = useOnlyOfficeContext()\n  const [isRenaming, setIsRenaming] = useState(false)\n\n  const parentFolderPath = makeParentFolderPath(file)\n\n  const onRename = useCallback(() => setIsRenaming(true), [setIsRenaming])\n  const onRenameFinished = useCallback(\n    () => setIsRenaming(false),\n    [setIsRenaming]\n  )\n\n  return (\n    <div\n      className={`${styles['fileName']} u-mh-1 u-mh-half-s u-ellipsis u-flex-grow-1`}\n    >\n      {isRenaming ? (\n        <Typography variant=\"h6\" noWrap>\n          <RenameInput\n            className={styles['filename-renameInput']}\n            file={file}\n            withoutExtension\n            refreshFolderContent={onRenameFinished}\n            onAbort={onRenameFinished}\n          />\n        </Typography>\n      ) : (\n        <Typography\n          className={cx(muiStyles.name, {\n            [`${muiStyles.renamable}`]: !isReadOnly\n          })}\n          variant=\"h6\"\n          noWrap\n          onClick={!isReadOnly ? onRename : undefined}\n        >\n          {file.name}\n        </Typography>\n      )}\n      {parentFolderPath && !isMobile && !isPublic && (\n        <Link\n          data-testid=\"onlyoffice-filename-path\"\n          to={`/folder/${file.dir_id}`}\n          className={filelistStyles['fil-file-path']}\n        >\n          <Typography variant=\"caption\">\n            <MidEllipsis text={parentFolderPath} />\n          </Typography>\n        </Link>\n      )}\n    </div>\n  )\n}\n\nFileName.propTypes = {\n  file: PropTypes.object.isRequired,\n  isPublic: PropTypes.bool\n}\n\nexport default React.memo(FileName)\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/HomeIcon.jsx",
    "content": "import React from 'react'\n\nimport { useClient } from 'cozy-client'\n\nconst HomeIcon = () => {\n  const client = useClient()\n\n  return (\n    <div className=\"u-h-2 u-w-2 u-ml-1\">\n      <img\n        className=\"u-w-100 u-h-100 u-maw-2 u-mah-2\"\n        src={`${client.getStackClient().uri}/assets/images/icon-cozy-home.svg`}\n      />\n    </div>\n  )\n}\n\nexport default HomeIcon\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/HomeLinker.jsx",
    "content": "import React from 'react'\n\nimport {\n  useClient,\n  useQuery,\n  isQueryLoading,\n  generateWebLink\n} from 'cozy-client'\nimport AppLinker from 'cozy-ui-plus/dist/AppLinker'\nimport { useI18n } from 'twake-i18n'\n\nimport { computeHomeApp } from '@/modules/views/OnlyOffice/Toolbar/helpers'\nimport { buildAppsQuery, buildSettingsByIdQuery } from '@/queries'\n\nconst HomeLinker = ({ children }) => {\n  const { t } = useI18n()\n  const client = useClient()\n  const appsQuery = buildAppsQuery()\n  const contextQuery = buildSettingsByIdQuery('context')\n  const appsResult = useQuery(appsQuery.definition, appsQuery.options)\n  const contextResult = useQuery(contextQuery.definition, contextQuery.options)\n\n  if (isQueryLoading(appsResult) || isQueryLoading(contextResult)) {\n    return <>{children}</>\n  }\n\n  const homeApp = computeHomeApp({\n    apps: appsResult.data,\n    context: contextResult.data\n  })\n\n  const homeHref = generateWebLink({\n    cozyUrl: client.getStackClient().uri,\n    slug: homeApp.slug,\n    pathname: '/',\n    subDomainType: client.getInstanceOptions().subdomain\n  })\n\n  return (\n    <AppLinker app={homeApp} href={homeHref}>\n      {({ onClick, href }) => (\n        <a\n          href={href}\n          onClick={onClick}\n          aria-label={t('OnlyOffice.toolbar.goToHome')}\n        >\n          {children}\n        </a>\n      )}\n    </AppLinker>\n  )\n}\n\nexport default HomeLinker\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/Separator.jsx",
    "content": "import React from 'react'\n\nimport styles from './styles.styl'\n\nconst Separator = () => {\n  return <span className={styles['separator']} />\n}\n\nexport default Separator\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/Sharing.jsx",
    "content": "import React, { useState, useCallback } from 'react'\n\nimport flag from 'cozy-flags'\nimport { ShareButton, ShareModal, SharedRecipients } from 'cozy-sharing'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport IconButton from 'cozy-ui/transpiled/react/IconButton'\nimport ShareIcon from 'cozy-ui/transpiled/react/Icons/Share'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nconst Sharing = ({ file }) => {\n  const [showShareModal, setShowShareModal] = useState(false)\n  const { isMobile } = useBreakpoints()\n\n  const toggleShareModal = useCallback(\n    () => setShowShareModal(v => !v),\n    [setShowShareModal]\n  )\n\n  return (\n    <>\n      {isMobile ? (\n        <IconButton\n          data-testid=\"onlyoffice-sharing-icon\"\n          onClick={toggleShareModal}\n          size=\"medium\"\n        >\n          <Icon icon={ShareIcon} />\n        </IconButton>\n      ) : (\n        <>\n          <SharedRecipients\n            docId={file._id}\n            size={32}\n            onClick={toggleShareModal}\n          />\n          <ShareButton\n            data-testid=\"onlyoffice-sharing-button\"\n            docId={file._id}\n            onClick={toggleShareModal}\n          />\n        </>\n      )}\n      {showShareModal && (\n        <ShareModal\n          document={file}\n          documentType=\"Files\"\n          sharingDesc={file.name}\n          onClose={toggleShareModal}\n          autoOpenShareRestriction={flag('sharing.auto-open-settings.enabled')}\n          showGenerateLinkButton={flag('sharing.generate-link-button.enabled')}\n        />\n      )}\n    </>\n  )\n}\n\nexport default React.memo(Sharing)\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/SummarizeByAIButtonWrapper.tsx",
    "content": "import React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport SummarizeByAIButton from 'cozy-viewer/dist/components/SummarizeByAIButton'\nimport { withViewerLocales } from 'cozy-viewer/dist/hoc/withViewerLocales'\n\nconst SummarizeByAIButtonWrapper: React.FC<{ isLoaded: boolean }> = ({\n  isLoaded\n}) => {\n  const navigate = useNavigate()\n\n  const redirectToPaywall = (): void => {\n    navigate('v/ai/paywall', { replace: true })\n  }\n\n  return (\n    <>\n      {isLoaded ? (\n        <SummarizeByAIButton\n          className=\"u-mr-half\"\n          onPaywallRedirect={redirectToPaywall}\n        />\n      ) : null}\n    </>\n  )\n}\n\nexport default withViewerLocales(SummarizeByAIButtonWrapper)\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/helpers.js",
    "content": "// TODO: use this method from cozy-client instead\nexport const computeHomeApp = ({ apps, context }) => {\n  const defaultRedirection =\n    context && context.attributes && context.attributes.default_redirection\n  let homeApp\n\n  if (!defaultRedirection) {\n    homeApp = apps.find(app => app.slug === 'home')\n  } else {\n    const slugRegexp = /^([^/]+)\\/.*/\n    const matches = defaultRedirection.match(slugRegexp)\n    const defaultAppSlug = matches && matches[1]\n    homeApp = apps.find(app => app.slug === defaultAppSlug)\n  }\n\n  return homeApp\n}\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/index.jsx",
    "content": "import React from 'react'\nimport { useSearchParams } from 'react-router-dom'\n\nimport { useQuery } from 'cozy-client'\nimport {\n  addToCozySharingLink,\n  createCozySharingLink,\n  syncToCozySharingLink,\n  OpenSharingLinkButton\n} from 'cozy-sharing'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport FilesRealTimeQueries from '@/components/FilesRealTimeQueries'\nimport { useRedirectLink } from '@/hooks/useRedirectLink'\nimport PublicToolbarMoreMenu from '@/modules/public/PublicToolbarMoreMenu'\nimport { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\nimport BackButton from '@/modules/views/OnlyOffice/Toolbar/BackButton'\nimport EditButton from '@/modules/views/OnlyOffice/Toolbar/EditButton'\nimport FileIcon from '@/modules/views/OnlyOffice/Toolbar/FileIcon'\nimport FileName from '@/modules/views/OnlyOffice/Toolbar/FileName'\nimport HomeIcon from '@/modules/views/OnlyOffice/Toolbar/HomeIcon'\nimport HomeLinker from '@/modules/views/OnlyOffice/Toolbar/HomeLinker'\nimport Separator from '@/modules/views/OnlyOffice/Toolbar/Separator'\nimport Sharing from '@/modules/views/OnlyOffice/Toolbar/Sharing'\nimport SummarizeByAIButtonWrapper from '@/modules/views/OnlyOffice/Toolbar/SummarizeByAIButtonWrapper'\nimport { isOfficeEditingEnabled } from '@/modules/views/OnlyOffice/helpers'\nimport { buildFileOrFolderByIdQuery, buildFileWhereByIdQuery } from '@/queries'\n\nconst Toolbar = ({ sharingInfos }) => {\n  const { isMobile, isDesktop } = useBreakpoints()\n  const [searchParams] = useSearchParams(window.location.search)\n  const { isEditorReady, isReadOnly, isTrashed, fileId, isPublic } =\n    useOnlyOfficeContext()\n  const { t } = useI18n()\n  const { redirectBack, canRedirect } = useRedirectLink({ isPublic })\n\n  const fileQuery = isPublic\n    ? buildFileOrFolderByIdQuery(fileId) // do not return path but return correctly data in public context\n    : buildFileWhereByIdQuery(fileId) // return path but get a 403 in public context\n\n  const { data } = useQuery(fileQuery.definition, fileQuery.options)\n  const file = Array.isArray(data) ? data[0] : data\n\n  if (!file) return null\n\n  const {\n    addSharingLink,\n    syncSharingLink,\n    createCozyLink,\n    isSharingShortcutCreated,\n    loading\n  } = sharingInfos\n\n  const showBackButton = canRedirect\n\n  const handleOnClick = () => {\n    redirectBack()\n  }\n\n  // Check if the share shortcut has not yet been added\n  const isShareNotAdded = !loading && !isSharingShortcutCreated\n  // Check if you are sharing Cozy to Cozy (Link sharing is on the `/public` route)\n  const isCozyToCozySharing = window.location.pathname === '/preview'\n  // Check if you are sharing Cozy to Cozy synced (Also on the `/public` route)\n  const isCozyToCozySharingSynced = searchParams.has('username')\n\n  // addSharingLink exists only in cozy to cozy sharing\n  const link = isCozyToCozySharing ? addSharingLink : createCozyLink\n  const actions = makeActions(\n    [\n      !isCozyToCozySharing && createCozySharingLink,\n      isCozyToCozySharing && addToCozySharingLink,\n      isCozyToCozySharing && syncToCozySharingLink\n    ],\n    {\n      t,\n      addSharingLink,\n      syncSharingLink,\n      createCozyLink,\n      isSharingShortcutCreated\n    }\n  )\n  const canEdit =\n    isEditorReady &&\n    !isReadOnly &&\n    !isTrashed &&\n    isOfficeEditingEnabled(isDesktop)\n\n  const showPublicEditButton = isPublic && !isMobile && canEdit\n\n  const showSharingLinkButton =\n    isPublic && !isMobile && isShareNotAdded && !isCozyToCozySharingSynced\n\n  return (\n    <>\n      <FilesRealTimeQueries />\n      <div className=\"u-flex u-flex-items-center u-flex-grow-1 u-ellipsis\">\n        {!isMobile && (\n          <>\n            {isPublic ? (\n              <HomeIcon />\n            ) : (\n              <HomeLinker>\n                <HomeIcon />\n              </HomeLinker>\n            )}\n            <Separator />\n          </>\n        )}\n        {showBackButton && <BackButton onClick={handleOnClick} />}\n        {!isMobile && file.class && <FileIcon fileClass={file.class} />}\n        <FileName file={file} isPublic={isPublic} />\n      </div>\n      {showSharingLinkButton && (\n        <OpenSharingLinkButton\n          link={link}\n          isSharingShortcutCreated={isSharingShortcutCreated}\n          variant={showPublicEditButton ? 'secondary' : 'primary'}\n        />\n      )}\n      {showPublicEditButton && <EditButton />}\n\n      {isPublic && !isCozyToCozySharingSynced && (\n        <PublicToolbarMoreMenu files={[file]} actions={actions} />\n      )}\n\n      <SummarizeByAIButtonWrapper isLoaded={isEditorReady} />\n\n      {!isPublic && isEditorReady && (\n        <>\n          <Sharing file={file} />\n          {canEdit && <EditButton />}\n        </>\n      )}\n    </>\n  )\n}\n\nexport default React.memo(Toolbar)\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/index.spec.jsx",
    "content": "import { render, fireEvent, screen } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient, useQuery } from 'cozy-client'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport AppLike from 'test/components/AppLike'\nimport { officeDoc } from 'test/data'\n\nimport * as hookHelpers from '@/hooks/helpers'\nimport { OnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\nimport Toolbar from '@/modules/views/OnlyOffice/Toolbar'\n\njest.mock('cozy-sharing', () => ({\n  ...jest.requireActual('cozy-sharing'),\n  __esModule: true,\n  OpenSharingLinkButton: () => <div data-testid=\"open-external-link-button\" />\n}))\n\njest.mock(\n  '@/modules/views/OnlyOffice/Toolbar/HomeLinker',\n  () =>\n    ({ children }) => <div data-testid=\"HomeLinker\">{children}</div>\n)\n\njest.mock('cozy-ui/transpiled/react/providers/Breakpoints', () => ({\n  ...jest.requireActual('cozy-ui/transpiled/react/providers/Breakpoints'),\n  __esModule: true,\n  default: jest.fn(),\n  useBreakpoints: jest.fn()\n}))\njest.mock('cozy-client/dist/hooks/useQuery', () => jest.fn())\njest.mock('modules/views/OnlyOffice/Toolbar/helpers', () => ({\n  ...jest.requireActual('modules/views/OnlyOffice/Toolbar/helpers'),\n  computeHomeApp: jest.fn(() => ({ slug: 'slug' }))\n}))\n\nconst mockNavigate = jest.fn()\njest.mock('react-router-dom', () => ({\n  ...jest.requireActual('react-router-dom'),\n  useNavigate: () => mockNavigate\n}))\n\njest.mock('cozy-client/dist/models/file', () => ({\n  ...jest.requireActual('cozy-client/dist/models/file'),\n  ensureFilePath: jest.fn(doc => ({ ...doc, path: '/' }))\n}))\n\njest.mock(\n  '@/modules/views/OnlyOffice/Toolbar/SummarizeByAIButtonWrapper',\n  () => ({\n    __esModule: true,\n    default: () => null\n  })\n)\n\nconst client = createMockClient({})\nclient.stackClient.uri = 'http://cozy.tools'\nclient.plugins = {\n  realtime: {\n    subscribe: () => {},\n    unsubscribe: () => {}\n  }\n}\nclient.collection = () => ({\n  fetchOwnPermissions: () =>\n    Promise.resolve({\n      included: []\n    })\n})\nconst defaultSharingInfos = {\n  addSharingLink: '',\n  isSharingShortcutCreated: false,\n  loading: false,\n  createCozyLink: 'http://cozy.tools'\n}\nconst setup = ({\n  isReadOnly = false,\n  isPublic = false,\n  isFromSharing = false,\n  isMobile = false,\n  isEditorModeView = false,\n  sharingInfos = {}\n} = {}) => {\n  const sharingInfosProps = {\n    ...defaultSharingInfos,\n    ...sharingInfos\n  }\n  useBreakpoints.mockReturnValue({ isMobile })\n\n  const root = render(\n    <AppLike\n      client={client}\n      sharingContextValue={{\n        byDocId: { 123: {} },\n        documentType: 'Files'\n      }}\n    >\n      <OnlyOfficeContext.Provider\n        value={{\n          fileId: officeDoc.id,\n          isPublic,\n          isEditorModeView,\n          isFromSharing,\n          isReadOnly,\n          isEditorReady: true\n        }}\n      >\n        <Toolbar sharingInfos={sharingInfosProps} />\n      </OnlyOfficeContext.Provider>\n    </AppLike>\n  )\n\n  return { root }\n}\n\ndescribe('Toolbar', () => {\n  beforeEach(() => {\n    jest.spyOn(console, 'warn').mockImplementation()\n    jest.clearAllMocks()\n  })\n\n  describe('FileName', () => {\n    it('should show the path', () => {\n      // TODO : analyse why BaseButton has incorrect props and remove this consoleSpy\n      jest.spyOn(console, 'error').mockImplementation()\n\n      useQuery.mockReturnValue({ data: [officeDoc] })\n\n      const { root } = setup({ isMobile: false })\n      const { queryByTestId } = root\n\n      expect(queryByTestId('onlyoffice-filename-path')).toBeTruthy()\n    })\n\n    it('should not show the path on mobile', () => {\n      useQuery.mockReturnValue({ data: [officeDoc] })\n\n      const { root } = setup({ isMobile: true })\n      const { queryByTestId } = root\n\n      expect(queryByTestId('onlyoffice-filename-path')).toBeFalsy()\n    })\n  })\n\n  describe('Renaming', () => {\n    it('should be able to rename the file if not in readOnly mode', () => {\n      useQuery.mockReturnValue({ data: [officeDoc] })\n\n      const { root } = setup({ isReadOnly: false })\n      const { getByText, getByRole } = root\n\n      fireEvent.click(getByText(officeDoc.name))\n      expect(getByRole('textbox').value).toBe(officeDoc.name)\n    })\n\n    it('should not be able to rename the file in readOnly mode', () => {\n      useQuery.mockReturnValue({ data: [officeDoc] })\n\n      const { root } = setup({ isReadOnly: true })\n      const { getByText, queryByRole } = root\n\n      fireEvent.click(getByText(officeDoc.name))\n      expect(queryByRole('textbox')).toBeFalsy()\n    })\n\n    describe('Renaming on mobile', () => {\n      it('should be able to rename the file if not in readOnly mode', () => {\n        useQuery.mockReturnValue({ data: [officeDoc] })\n\n        const { root } = setup({ isReadOnly: false, isMobile: true })\n        const { getByText, getByRole } = root\n\n        fireEvent.click(getByText(officeDoc.name))\n        expect(getByRole('textbox').value).toBe(officeDoc.name)\n      })\n    })\n  })\n\n  describe('Sharing', () => {\n    describe('Private view', () => {\n      it('should show sharing button', () => {\n        useQuery.mockReturnValue({ data: [officeDoc] })\n\n        const { root } = setup({ isPublic: false })\n        const { queryByTestId } = root\n\n        expect(queryByTestId('onlyoffice-sharing-button')).toBeTruthy()\n      })\n      it('should not show more menu', () => {\n        useQuery.mockReturnValue({ data: [officeDoc] })\n\n        const { root } = setup({ isPublic: false })\n        const { queryByTestId } = root\n\n        expect(queryByTestId('more-menu')).toBeNull()\n      })\n\n      describe('On mobile', () => {\n        it('should show sharing icon', () => {\n          useQuery.mockReturnValue({ data: [officeDoc] })\n\n          const { root } = setup({ isPublic: false, isMobile: true })\n          const { queryByTestId } = root\n\n          expect(queryByTestId('onlyoffice-sharing-button')).toBeFalsy()\n          expect(queryByTestId('onlyoffice-sharing-icon')).toBeTruthy()\n        })\n        it('should not show more menu', () => {\n          useQuery.mockReturnValue({ data: [officeDoc] })\n\n          const { root } = setup({ isPublic: false, isMobile: true })\n          const { queryByTestId } = root\n\n          expect(queryByTestId('more-menu')).toBeNull()\n        })\n      })\n    })\n\n    describe('Public view', () => {\n      describe('Cozy to Cozy', () => {\n        it('should not show sharing button', () => {\n          useQuery.mockReturnValue({ data: [officeDoc] })\n\n          const sharingInfos = {\n            isSharingShortcutCreated: false\n          }\n          const { root } = setup({ isPublic: true, sharingInfos })\n          const { queryByTestId } = root\n\n          expect(queryByTestId('onlyoffice-sharing-button')).toBeNull()\n        })\n        describe(\"Sharing is not added to the recipient's Cozy\", () => {\n          it('should show \"MoreMenu\" button', () => {\n            useQuery.mockReturnValue({ data: [officeDoc] })\n\n            const sharingInfos = {\n              isSharingShortcutCreated: false\n            }\n            const { root } = setup({ isPublic: true, sharingInfos })\n            const { queryByTestId } = root\n\n            expect(queryByTestId('more-menu')).toBeTruthy()\n          })\n          it('should show \"Add to my Cozy\" button', () => {\n            useQuery.mockReturnValue({ data: [officeDoc] })\n\n            const sharingInfos = {\n              isSharingShortcutCreated: false\n            }\n            const { root } = setup({ isPublic: true, sharingInfos })\n            const { queryByTestId } = root\n\n            expect(queryByTestId('open-external-link-button')).toBeTruthy()\n          })\n\n          describe('On mobile', () => {\n            it('should not show sharing icon and button', () => {\n              useQuery.mockReturnValue({ data: [officeDoc] })\n\n              const sharingInfos = {\n                isSharingShortcutCreated: false\n              }\n              const { root } = setup({\n                isPublic: true,\n                isMobile: true,\n                sharingInfos\n              })\n              const { queryByTestId } = root\n\n              expect(queryByTestId('onlyoffice-sharing-button')).toBeNull()\n              expect(queryByTestId('onlyoffice-sharing-icon')).toBeNull()\n            })\n            it('should show more menu', () => {\n              useQuery.mockReturnValue({ data: [officeDoc] })\n\n              const sharingInfos = {\n                isSharingShortcutCreated: false\n              }\n              const { root } = setup({\n                isPublic: true,\n                isMobile: true,\n                sharingInfos\n              })\n              const { queryByTestId } = root\n\n              expect(queryByTestId('more-menu')).toBeTruthy()\n            })\n          })\n        })\n\n        describe(\"Sharing is added to the recipient's Cozy (not sync)\", () => {\n          it('should not show sharing button', () => {\n            useQuery.mockReturnValue({ data: [officeDoc] })\n\n            const sharingInfos = {\n              isSharingShortcutCreated: true\n            }\n            const { root } = setup({ isPublic: true, sharingInfos })\n            const { queryByTestId } = root\n\n            expect(queryByTestId('onlyoffice-sharing-button')).toBeNull()\n          })\n          it('should show \"MoreMenu\" button', () => {\n            useQuery.mockReturnValue({ data: [officeDoc] })\n\n            const sharingInfos = {\n              isSharingShortcutCreated: true\n            }\n            const { root } = setup({ isPublic: true, sharingInfos })\n            const { queryByTestId } = root\n\n            expect(queryByTestId('more-menu')).toBeTruthy()\n          })\n          it('should not show \"Add to my Cozy\" button', () => {\n            useQuery.mockReturnValue({ data: [officeDoc] })\n\n            const sharingInfos = {\n              isSharingShortcutCreated: true\n            }\n            const { root } = setup({ isPublic: true, sharingInfos })\n            const { queryByTestId } = root\n\n            expect(queryByTestId('open-external-link-button')).toBeNull()\n          })\n\n          describe('On mobile', () => {\n            it('should not show sharing icon and button', () => {\n              useQuery.mockReturnValue({ data: [officeDoc] })\n\n              const sharingInfos = {\n                isSharingShortcutCreated: true\n              }\n              const { root } = setup({\n                isPublic: true,\n                isMobile: true,\n                sharingInfos\n              })\n              const { queryByTestId } = root\n\n              expect(queryByTestId('onlyoffice-sharing-button')).toBeNull()\n              expect(queryByTestId('onlyoffice-sharing-icon')).toBeNull()\n            })\n            it('should show more menu', () => {\n              useQuery.mockReturnValue({ data: [officeDoc] })\n\n              const sharingInfos = {\n                isSharingShortcutCreated: true\n              }\n              const { root } = setup({\n                isPublic: true,\n                isMobile: true,\n                sharingInfos\n              })\n              const { queryByTestId } = root\n\n              expect(queryByTestId('more-menu')).toBeTruthy()\n            })\n          })\n        })\n      })\n    })\n  })\n\n  const makeNewLocation = (path = '') => {\n    window.history.replaceState({}, '', path)\n  }\n\n  describe('Back Button', () => {\n    afterEach(() => {\n      window.history.replaceState({}, '', '/')\n    })\n\n    it('should hide without redirect link into searchParam', () => {\n      makeNewLocation('#/onlyoffice/123')\n      useQuery.mockReturnValue({ data: [officeDoc] })\n\n      setup()\n\n      const backButton = screen.queryByRole('button', {\n        name: 'Back'\n      })\n      expect(backButton).toBeNull()\n    })\n\n    it('should redirect to previous folder with #/hash?searchParam ', async () => {\n      makeNewLocation('#/onlyoffice/123?redirectLink=drive%23%2Ffolder%2F321')\n      useQuery.mockReturnValue({ data: [officeDoc] })\n\n      setup()\n\n      const button = await screen.findByRole('button', {\n        name: 'Back'\n      })\n\n      fireEvent.click(button)\n\n      expect(mockNavigate).toHaveBeenCalledWith('/folder/321')\n    })\n\n    it('should redirect to previous folder from current cozy user', async () => {\n      const spyOnChangeLocation = jest.spyOn(hookHelpers, 'changeLocation')\n      client.collection = () => ({\n        fetchOwnPermissions: () =>\n          Promise.resolve({\n            included: [\n              {\n                attributes: {\n                  instance: 'http://other.tools/'\n                }\n              }\n            ]\n          })\n      })\n      makeNewLocation('?redirectLink=drive%23%2Ffolder%2F321#/onlyoffice/123')\n      useQuery.mockReturnValue({ data: [officeDoc] })\n\n      setup({\n        isPublic: true\n      })\n\n      const button = await screen.findByRole('button', {\n        name: 'Back'\n      })\n\n      fireEvent.click(button)\n\n      expect(spyOnChangeLocation).toHaveBeenCalledWith(\n        'http://other-drive.tools/#/folder/321'\n      )\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/Toolbar/styles.styl",
    "content": "@require 'tools/mixins.styl'\n\n.separator\n    height 1.75rem\n    width rem(1)\n    margin-left .75rem\n    margin-right .75rem\n    background-color var(--dividerColor)\n\n.fileName\n    width 40%\n\n.filename-renameInput\n    display block\n    max-width 90%\n\n    input[type=text]\n        padding rem(2) rem(4)\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/View.jsx",
    "content": "import PropTypes from 'prop-types'\nimport React, { useEffect, useCallback, useState } from 'react'\n\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport Error from '@/modules/views/OnlyOffice/Error'\nimport OnlyOfficeAIAssistantPanel from '@/modules/views/OnlyOffice/OnlyOfficeAIAssistantPanel'\nimport { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\nimport ReadOnlyFab from '@/modules/views/OnlyOffice/ReadOnlyFab'\nimport { FRAME_EDITOR_NAME } from '@/modules/views/OnlyOffice/config'\nimport { isOfficeEditingEnabled } from '@/modules/views/OnlyOffice/helpers'\n\nconst forceIframeHeight = value => {\n  const iframe = document.getElementsByName(FRAME_EDITOR_NAME)[0]\n  if (iframe) iframe.style.height = value\n}\n\nconst View = ({ id, apiUrl, docEditorConfig }) => {\n  const [isError, setIsError] = useState(false)\n\n  const { isEditorReady, isReadOnly, isTrashed } = useOnlyOfficeContext()\n  const { isMobile, isDesktop } = useBreakpoints()\n\n  const initEditor = useCallback(() => {\n    new window.DocsAPI.DocEditor('onlyOfficeEditor', docEditorConfig)\n    forceIframeHeight('0')\n  }, [docEditorConfig])\n\n  const handleError = useCallback(() => {\n    const scriptNode = document.getElementById(id)\n    scriptNode && scriptNode.remove()\n    setIsError(true)\n  }, [setIsError, id])\n\n  useEffect(() => {\n    const scriptAlreadyLoaded = document.getElementById(id)\n    if (scriptAlreadyLoaded) return initEditor()\n\n    const script = document.createElement('script')\n    script.id = id\n    script.src = apiUrl\n    script.async = true\n    script.onload = () => initEditor()\n    script.onerror = () => handleError()\n\n    document.body.appendChild(script)\n  }, [id, apiUrl, initEditor, handleError])\n\n  useEffect(() => {\n    if (isEditorReady) {\n      forceIframeHeight('100%')\n    }\n  }, [isEditorReady])\n\n  const showReadOnlyFab =\n    isMobile &&\n    isEditorReady &&\n    !isReadOnly &&\n    !isTrashed &&\n    isOfficeEditingEnabled(isDesktop)\n\n  if (isError) return <Error />\n\n  return (\n    <>\n      {!isEditorReady && (\n        <div className=\"u-flex u-flex-items-center u-flex-justify-center u-flex-grow-1\">\n          <Spinner size=\"xxlarge\" />\n        </div>\n      )}\n      <div className=\"u-flex u-flex-grow-1\">\n        <div id=\"onlyOfficeEditor\" />\n        <OnlyOfficeAIAssistantPanel />\n      </div>\n      {showReadOnlyFab && <ReadOnlyFab />}\n    </>\n  )\n}\n\nView.propTypes = {\n  id: PropTypes.string.isRequired,\n  apiUrl: PropTypes.string.isRequired,\n  docEditorConfig: PropTypes.object.isRequired\n}\n\nexport default React.memo(View)\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/components/FileDeletedModal.jsx",
    "content": "import React, { useState } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport Alert from 'cozy-ui/transpiled/react/Alert'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport { useI18n } from 'twake-i18n'\n\nimport { useRedirectLink } from '@/hooks/useRedirectLink'\nimport { DOCTYPE_FILES } from '@/lib/doctypes'\nimport { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\nimport { makeOnlyOfficeFileRoute } from '@/modules/views/OnlyOffice/helpers'\n\nconst FileDeletedModal = () => {\n  const { fileId, setFileDeleted, editorMode, isPublic } =\n    useOnlyOfficeContext()\n  const navigate = useNavigate()\n  const client = useClient()\n  const { t } = useI18n()\n\n  const [isErrorAlertDisplayed, setErrorAlertDisplayed] = useState(false)\n  const [isBusy, setBusy] = useState(false)\n\n  const { redirectLink, redirectBack } = useRedirectLink({ isPublic })\n\n  const restore = async () => {\n    setErrorAlertDisplayed(false)\n    setBusy(isBusy)\n    try {\n      const resp = await client.collection(DOCTYPE_FILES).restore(fileId)\n      const route = makeOnlyOfficeFileRoute(resp.data.id, {\n        fromRedirect: redirectLink,\n        fromEdit: editorMode === 'edit'\n      })\n      navigate(route)\n      setFileDeleted(false)\n    } catch {\n      setErrorAlertDisplayed(true)\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  const goBack = () => {\n    redirectBack()\n  }\n\n  return (\n    <ConfirmDialog\n      open\n      title={t('FileDeletedModal.title')}\n      content={\n        <>\n          {isErrorAlertDisplayed ? (\n            <Alert severity=\"error\" className=\"u-mb-1\">\n              {t('FileDeletedModal.error')}\n            </Alert>\n          ) : null}\n          <Typography>{t('FileDeletedModal.content')}</Typography>\n        </>\n      }\n      actions={\n        <>\n          <Buttons\n            disabled={isBusy}\n            variant=\"secondary\"\n            color=\"error\"\n            label={t('FileDeletedModal.cancel')}\n            onClick={goBack}\n          />\n          <Buttons\n            busy={isBusy}\n            disabled={isBusy}\n            label={t('FileDeletedModal.confirm')}\n            onClick={restore}\n          />\n        </>\n      }\n    />\n  )\n}\n\nexport { FileDeletedModal }\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/components/FileDivergedModal.jsx",
    "content": "import React, { useState } from 'react'\nimport { useNavigate, useSearchParams } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport Alert from 'cozy-ui/transpiled/react/Alert'\nimport Buttons from 'cozy-ui/transpiled/react/Buttons'\nimport { ConfirmDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Typography from 'cozy-ui/transpiled/react/Typography'\nimport { useI18n } from 'twake-i18n'\n\nimport { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\nimport { makeOnlyOfficeFileRoute } from '@/modules/views/OnlyOffice/helpers'\n\nconst FileDivergedModal = () => {\n  const { officeKey, setFileDiverged, editorMode } = useOnlyOfficeContext()\n  const navigate = useNavigate()\n  const client = useClient()\n  const { t } = useI18n()\n\n  const [isErrorAlertDisplayed, setErrorAlertDisplayed] = useState(false)\n  const [isBusy, setBusy] = useState(false)\n  const [shouldConfirmReload, setShouldConfirmReload] = useState(false)\n\n  const [searchParams] = useSearchParams()\n  const params = new URLSearchParams(location.search)\n\n  const redirectLink =\n    searchParams.get('redirectLink') || params.get('redirectLink')\n\n  const continueEditing = async () => {\n    setErrorAlertDisplayed(false)\n    setBusy(true)\n    try {\n      const resp = await client\n        .getStackClient()\n        .fetchJSON('POST', `/office/keys/${officeKey}`)\n      const route = makeOnlyOfficeFileRoute(resp.data.id, {\n        fromRedirect: redirectLink,\n        fromEdit: editorMode === 'edit'\n      })\n      navigate(route)\n      setFileDiverged(false)\n    } catch {\n      setErrorAlertDisplayed(true)\n    } finally {\n      setBusy(false)\n    }\n  }\n\n  const goToNewVersion = () => {\n    location.reload()\n  }\n\n  const toogleConfirmReloadModal = () => {\n    setShouldConfirmReload(!shouldConfirmReload)\n  }\n\n  if (editorMode === 'view') {\n    return (\n      <ConfirmDialog\n        open\n        title={t('FileDivergedModal.viewMode.title')}\n        content={t('FileDivergedModal.viewMode.content')}\n        actions={\n          <Buttons\n            label={t('FileDivergedModal.viewMode.confirm')}\n            onClick={goToNewVersion}\n          />\n        }\n      />\n    )\n  }\n\n  return (\n    <>\n      <ConfirmDialog\n        open\n        title={t('FileDivergedModal.title')}\n        content={\n          <>\n            {isErrorAlertDisplayed ? (\n              <Alert severity=\"error\" className=\"u-mb-1\">\n                {t('FileDivergedModal.error')}\n              </Alert>\n            ) : null}\n            <Typography>{t('FileDivergedModal.content')}</Typography>\n          </>\n        }\n        actions={\n          <>\n            <Buttons\n              variant=\"secondary\"\n              disabled={isBusy}\n              label={t('FileDivergedModal.cancel')}\n              onClick={toogleConfirmReloadModal}\n            />\n            <Buttons\n              busy={isBusy}\n              disabled={isBusy}\n              label={t('FileDivergedModal.confirm')}\n              onClick={continueEditing}\n            />\n          </>\n        }\n      />\n      {shouldConfirmReload ? (\n        <ConfirmDialog\n          open\n          title={t('FileDivergedModal.confirmReload.title')}\n          onClose={toogleConfirmReloadModal}\n          content={t('FileDivergedModal.confirmReload.content')}\n          actions={\n            <>\n              <Buttons\n                variant=\"secondary\"\n                label={t('FileDivergedModal.confirmReload.cancel')}\n                onClick={toogleConfirmReloadModal}\n              />\n              <Buttons\n                label={t('FileDivergedModal.confirmReload.confirm')}\n                onClick={goToNewVersion}\n              />\n            </>\n          }\n        />\n      ) : null}\n    </>\n  )\n}\n\nexport { FileDivergedModal }\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/config.js",
    "content": "export const FRAME_EDITOR_NAME = 'frameEditor'\n\nexport const DEFAULT_EDITOR_TOOLBAR_HEIGHT_IOS = 68\nexport const DEFAULT_EDITOR_TOOLBAR_HEIGHT = 32\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/helpers.js",
    "content": "import { isMobile } from 'cozy-device-helper'\nimport flag from 'cozy-flags'\nimport FileTypeSheetIcon from 'cozy-ui/transpiled/react/Icons/FileTypeSheet'\nimport FileTypeSlideIcon from 'cozy-ui/transpiled/react/Icons/FileTypeSlide'\nimport FileTypeTextIcon from 'cozy-ui/transpiled/react/Icons/FileTypeText'\n\n/**\n * Checks if the Office feature is enabled.\n *\n * @param {boolean} isDesktop - Indicates whether the application is running on a desktop platform.\n * @returns {boolean} - Returns true if the Office feature is enabled, false otherwise.\n */\nexport const isOfficeEnabled = isDesktop => {\n  const officeEnabled = flag(\n    `drive.office.${!isDesktop || isMobile() ? 'touchScreen.' : ''}enabled`\n  )\n  if (officeEnabled !== null) {\n    return officeEnabled\n  }\n\n  // Keeping compatibility with the old flag but with a lower priority\n  if (flag('drive.onlyoffice.enabled')) return true\n\n  return false\n}\n\nexport function canWriteOfficeDocument() {\n  const officeWrite = flag('drive.office.write')\n  if (officeWrite !== null) {\n    return officeWrite\n  }\n\n  // Keeping compatibility with the old flag but with a lower priority\n  if (flag('drive.onlyoffice.enabled')) return true\n\n  return false\n}\n\nexport function officeDefaultMode(isDesktop, isMobile) {\n  if (!isDesktop && flag('drive.office.touchScreen.readOnly')) {\n    return 'view'\n  }\n\n  const canWrite = canWriteOfficeDocument()\n\n  const mobileDefaultMode = flag('drive.office.mobile.defaultMode')\n  if (isMobile && canWrite && mobileDefaultMode !== null) {\n    return mobileDefaultMode\n  }\n\n  const defaultMode = flag('drive.office.defaultMode')\n  if (canWrite && defaultMode !== null) {\n    return defaultMode\n  }\n\n  return 'view'\n}\n\nexport const isOfficeEditingEnabled = isDesktop => {\n  if (!isOfficeEnabled(isDesktop)) {\n    return false\n  }\n\n  if ((!isDesktop || isMobile()) && flag('drive.office.touchScreen.readOnly')) {\n    return false\n  }\n\n  return true\n}\n\n/**\n * @typedef {Object} OnlyOfficeFileRouteOptions\n * @property {string} [driveId]\n * @property {boolean} [fromCreate] The document will be opened in edit mode\n * @property {string} [fromPathname] Hash to redirect the user when he back\n * @property {string} [fromRedirect] To forward existing redirectLink\n * @property {boolean} [fromEdit] The document will be opened in edit mode\n * @property {boolean} [fromPublicFolder] The document is opened from a public folder\n */\n\n/**\n * Make hash to redirect user to an OnlyOffice file\n * @param {string} fileId Id of the OnlyOffice file\n * @param {OnlyOfficeFileRouteOptions} [options] Options\n\n * @returns {string} Path to OnlyOffice\n */\nexport const makeOnlyOfficeFileRoute = (\n  fileId,\n  {\n    driveId,\n    fromCreate = false,\n    fromPathname,\n    fromRedirect,\n    fromEdit = false,\n    fromPublicFolder = false\n  } = {}\n) => {\n  const params = new URLSearchParams()\n  if (fromCreate) {\n    params.append('fromCreate', true)\n  }\n  if (fromPathname) {\n    params.append('redirectLink', `drive#${fromPathname}`)\n  }\n  if (fromRedirect) {\n    params.append('redirectLink', fromRedirect)\n  }\n  if (fromEdit) {\n    params.append('fromEdit', fromEdit)\n  }\n  if (fromPublicFolder) {\n    params.append('fromPublicFolder', fromPublicFolder)\n  }\n\n  const searchParam = params.size > 0 ? `?${params.toString()}` : ''\n\n  if (driveId) {\n    return `/onlyoffice/${driveId}/${fileId}${searchParam}`\n  }\n\n  return `/onlyoffice/${fileId}${searchParam}`\n}\n\n/**\n * Returns true in case of the document is shared and should be opened on another instance.\n * See https://docs.cozy.io/en/cozy-stack/office/#get-officeidopen\n * @param {object} params - Result of `/office/fileId/open`\n * @param {string} instanceUri - Current instanceUri\n * @returns {boolean}\n */\nexport const shouldBeOpenedOnOtherInstance = ({ data }, instanceUri) => {\n  return !!instanceUri && !instanceUri.includes(data.attributes.instance)\n}\n\nexport const makeOnlyOfficeIconByClass = fileClass => {\n  const iconByClass = {\n    spreadsheet: FileTypeSheetIcon,\n    slide: FileTypeSlideIcon,\n    text: FileTypeTextIcon\n  }\n\n  return iconByClass[fileClass]\n}\n\nexport const makeExtByClass = fileClass => {\n  const extByClass = {\n    text: 'docx',\n    spreadsheet: 'xlsx',\n    slide: 'pptx'\n  }\n\n  return extByClass[fileClass]\n}\n\nexport const makeMimeByClass = fileClass => {\n  // see https://developer.mozilla.org/fr/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types\n  const mimeByClass = {\n    text: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',\n    spreadsheet:\n      'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',\n    slide:\n      'application/vnd.openxmlformats-officedocument.presentationml.presentation'\n  }\n\n  return mimeByClass[fileClass]\n}\n\n// The sharing banner need to be shown only on the first arrival\n// and not after browsing inside a folder\n// When it comes from cozy to cozy sharing, we don't want the banner at all\nexport const showSharingBanner = ({\n  isFromSharing,\n  isPublic,\n  isInSharedFolder\n}) => {\n  return (\n    !isFromSharing &&\n    isPublic &&\n    (isInSharedFolder ? window.history.length <= 1 : window.history.length <= 2)\n  )\n}\n\n/**\n * Make username to use in the Only office editor in order to show the name\n * when adding comment or moving the cursor for example\n * @param {object} params - Params\n * @param {boolean} params.isPublic - Whether the route is public (like /public)\n * @param {boolean} params.isFromSharing - Whether the doc is shared from cozy to cozy\n * @param {string} params.username - The name of the sharing recipient\n * @param {string} params.public_name - The name of the owner\n * @returns {string|undefined}\n */\nexport const makeName = ({ isPublic, isFromSharing, username, public_name }) =>\n  isPublic && !isFromSharing ? undefined : username ? username : public_name\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/helpers.spec.js",
    "content": "import {\n  showSharingBanner,\n  makeName,\n  shouldBeOpenedOnOtherInstance\n} from '@/modules/views/OnlyOffice/helpers'\n\ndescribe('shouldBeOpenedOnOtherInstance', () => {\n  it('should return true if current instance is different from document instance', () => {\n    expect(\n      shouldBeOpenedOnOtherInstance(\n        {\n          data: {\n            attributes: {\n              instance: 'alice.cozy.localhost:8080'\n            }\n          }\n        },\n        'http://bob.cozy.localhost:8080'\n      )\n    ).toBe(true)\n  })\n\n  it('should return false if current instance is equal to document instance', () => {\n    expect(\n      shouldBeOpenedOnOtherInstance(\n        {\n          data: {\n            attributes: {\n              instance: 'alice.cozy.localhost:8080'\n            }\n          }\n        },\n        'http://alice.cozy.localhost:8080'\n      )\n    ).toBe(false)\n  })\n})\n\ndescribe('makeName', () => {\n  describe('for public route', () => {\n    it('should return undefined if it is not an accepted document from cozy to cozy sharing', () => {\n      expect(\n        makeName({\n          isPublic: true,\n          isFromSharing: false,\n          username: 'bob',\n          public_name: 'alice'\n        })\n      ).toBe(undefined)\n      expect(\n        makeName({\n          isPublic: true,\n          isFromSharing: false,\n          username: undefined,\n          public_name: 'alice'\n        })\n      ).toBe(undefined)\n      expect(\n        makeName({\n          isPublic: true,\n          isFromSharing: false,\n          username: 'bob',\n          public_name: undefined\n        })\n      ).toBe(undefined)\n      expect(\n        makeName({\n          isPublic: true,\n          isFromSharing: false,\n          username: undefined,\n          public_name: undefined\n        })\n      ).toBe(undefined)\n    })\n\n    it('should return the name of the sharing recipient for a document shared from cozy to cozy', () => {\n      expect(\n        makeName({\n          isPublic: true,\n          isFromSharing: true,\n          username: 'bob',\n          public_name: 'alice'\n        })\n      ).toBe('bob')\n      expect(\n        makeName({\n          isPublic: true,\n          isFromSharing: true,\n          username: 'bob',\n          public_name: undefined\n        })\n      ).toBe('bob')\n      expect(\n        makeName({\n          isPublic: true,\n          isFromSharing: true,\n          username: undefined,\n          public_name: undefined\n        })\n      ).toBe(undefined)\n    })\n  })\n\n  it('should return the public name if no sharing recipient', () => {\n    expect(\n      makeName({\n        isPublic: false,\n        isFromSharing: false,\n        username: undefined,\n        public_name: undefined\n      })\n    ).toBe(undefined)\n    expect(\n      makeName({\n        isPublic: false,\n        isFromSharing: false,\n        username: undefined,\n        public_name: 'alice'\n      })\n    ).toBe('alice')\n  })\n\n  it('should return the name of the sharing recipient if present', () => {\n    expect(\n      makeName({\n        isPublic: false,\n        isFromSharing: false,\n        username: 'bob',\n        public_name: 'alice'\n      })\n    ).toBe('bob')\n    expect(\n      makeName({\n        isPublic: false,\n        isFromSharing: false,\n        username: 'bob',\n        public_name: undefined\n      })\n    ).toBe('bob')\n  })\n})\n\ndescribe('showSharingBanner', () => {\n  describe('for 1 entry in history', () => {\n    it('should not show the banner when it comes from a synchronized cozy to cozy sharing', () => {\n      expect(window.history.length).toBe(1)\n\n      expect(\n        showSharingBanner({\n          isFromSharing: true,\n          isPublic: false,\n          isInSharedFolder: false\n        })\n      ).toBe(false)\n    })\n\n    it('should show the banner - preview for cozy to cozy sharing - coming from mail', () => {\n      expect(window.history.length).toBe(1)\n\n      expect(\n        showSharingBanner({\n          isFromSharing: false,\n          isPublic: true,\n          isInSharedFolder: false\n        })\n      ).toBe(true)\n    })\n  })\n\n  describe('for 2 entries in history', () => {\n    beforeAll(() => {\n      window.history.pushState('data', 'title', 'url')\n    })\n\n    it('should not show the banner when it comes from a synchronized cozy to cozy sharing', () => {\n      expect(window.history.length).toBe(2)\n\n      expect(\n        showSharingBanner({\n          isFromSharing: true,\n          isPublic: false,\n          isInSharedFolder: false\n        })\n      ).toBe(false)\n    })\n\n    it('should show the banner - preview for sharing by link, or cozy to cozy coming from shortcut', () => {\n      expect(window.history.length).toBe(2)\n\n      expect(\n        showSharingBanner({\n          isFromSharing: false,\n          isPublic: true,\n          isInSharedFolder: false\n        })\n      ).toBe(true)\n    })\n\n    it('should not show the banner - preview for cozy to cozy shared folder - coming from mail', () => {\n      expect(window.history.length).toBe(2)\n\n      expect(\n        showSharingBanner({\n          isFromSharing: false,\n          isPublic: true,\n          isInSharedFolder: true\n        })\n      ).toBe(false)\n    })\n  })\n\n  describe('for 3 entries in history', () => {\n    beforeAll(() => {\n      window.history.pushState('data', 'title', 'url')\n    })\n\n    it('should not show the banner when it comes from a synchronized cozy to cozy sharing', () => {\n      expect(window.history.length).toBe(3)\n\n      expect(\n        showSharingBanner({\n          isFromSharing: true,\n          isPublic: false,\n          isInSharedFolder: false\n        })\n      ).toBe(false)\n    })\n\n    it('should not show the banner - preview for cozy to cozy shared folder coming from shortcut, or for a folder shared by link', () => {\n      expect(window.history.length).toBe(3)\n\n      expect(\n        showSharingBanner({\n          isFromSharing: false,\n          isPublic: true,\n          isInSharedFolder: true\n        })\n      ).toBe(false)\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/index.jsx",
    "content": "import React from 'react'\nimport { useParams, Outlet } from 'react-router-dom'\n\nimport Dialog from 'cozy-ui/transpiled/react/Dialog'\n\nimport useHead from '@/components/useHead'\nimport Editor from '@/modules/views/OnlyOffice/Editor'\nimport { OnlyOfficeProvider } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\n\nconst OnlyOffice = ({\n  isPublic,\n  isReadOnly = false,\n  isFromSharing,\n  username,\n  isInSharedFolder\n}) => {\n  const { fileId, driveId } = useParams()\n  useHead()\n\n  return (\n    <Dialog open={true} fullScreen transitionDuration={0}>\n      <OnlyOfficeProvider\n        fileId={fileId}\n        driveId={driveId}\n        isPublic={isPublic}\n        isReadOnly={isReadOnly}\n        isFromSharing={isFromSharing}\n        username={username}\n        isInSharedFolder={isInSharedFolder}\n      >\n        <Editor />\n        <Outlet />\n      </OnlyOfficeProvider>\n    </Dialog>\n  )\n}\n\nexport default OnlyOffice\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/styles.styl",
    "content": ".ai-assistant-panel\n    width 30%\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/useConfig.jsx",
    "content": "import { useEffect, useState } from 'react'\nimport { useSearchParams } from 'react-router-dom'\n\nimport { useClient, isQueryLoading, generateWebLink } from 'cozy-client'\nimport useFetchJSON from 'cozy-client/dist/hooks/useFetchJSON'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport { useOnlyOfficeContext } from '@/modules/views/OnlyOffice/OnlyOfficeProvider'\nimport {\n  shouldBeOpenedOnOtherInstance,\n  isOfficeEnabled,\n  makeName\n} from '@/modules/views/OnlyOffice/helpers'\n\nconst useConfig = () => {\n  const {\n    fileId,\n    driveId,\n    setIsEditorReady,\n    isPublic,\n    username,\n    isFromSharing,\n    editorMode,\n    isEditorModeView,\n    setOfficeKey\n  } = useOnlyOfficeContext()\n  const client = useClient()\n  const instanceUri = client.getStackClient().uri\n  const [currentSearchParams] = useSearchParams()\n\n  const [config, setConfig] = useState()\n  const [status, setStatus] = useState('loading')\n\n  const queryResult = useFetchJSON(\n    'GET',\n    driveId\n      ? `/sharings/drives/${driveId}/office/${fileId}/open`\n      : `/office/${fileId}/open`\n  )\n  const { data, fetchStatus } = queryResult\n  const { isDesktop } = useBreakpoints()\n\n  useEffect(() => {\n    setStatus(fetchStatus)\n  }, [fetchStatus])\n\n  useEffect(() => {\n    setConfig()\n  }, [isEditorModeView])\n\n  useEffect(() => {\n    if (!isQueryLoading(queryResult) && fetchStatus !== 'error' && !config) {\n      if (shouldBeOpenedOnOtherInstance(data, instanceUri)) {\n        const {\n          protocol,\n          instance,\n          document_id,\n          subdomain,\n          sharecode,\n          public_name\n        } = data.data.attributes\n\n        const searchParams = [['sharecode', sharecode]]\n        searchParams.push(['isOnlyOfficeDocShared', true])\n        searchParams.push(['onlyOfficeDocId', document_id])\n        if (currentSearchParams.get('redirectLink')) {\n          searchParams.push([\n            'redirectLink',\n            currentSearchParams.get('redirectLink')\n          ])\n        }\n        if (public_name) searchParams.push(['username', public_name])\n\n        const link = generateWebLink({\n          cozyUrl: `${protocol}://${instance}`,\n          searchParams,\n          pathname: '/public/',\n          slug: 'drive',\n          subDomainType: subdomain\n        })\n\n        window.location = link\n      } else if (isOfficeEnabled(isDesktop)) {\n        const { attributes } = data.data\n        const { onlyoffice, public_name } = attributes\n        const name = makeName({\n          isPublic,\n          isFromSharing,\n          username,\n          public_name\n        })\n\n        setOfficeKey(onlyoffice.document.key)\n\n        const serverUrl = onlyoffice.url\n        const apiUrl = `${serverUrl}/web-apps/apps/api/documents/api.js`\n        const docEditorConfig = {\n          // complete config doc : https://api.onlyoffice.com/editors/advanced\n          document: onlyoffice.document,\n          editorConfig: {\n            ...onlyoffice.editor,\n            mode: onlyoffice.editor.mode === 'edit' ? editorMode : 'view',\n            user: { name },\n            customization: {\n              reviewDisplay: 'markup'\n            }\n          },\n          token: onlyoffice.token,\n          documentType: onlyoffice.documentType,\n          events: {\n            onAppReady: () => setIsEditorReady(true)\n          }\n        }\n\n        setConfig({ serverUrl, apiUrl, docEditorConfig })\n      } else {\n        setStatus('error')\n      }\n    }\n  }, [\n    editorMode,\n    queryResult,\n    fetchStatus,\n    data,\n    config,\n    setConfig,\n    setIsEditorReady,\n    isPublic,\n    username,\n    isFromSharing,\n    instanceUri,\n    isDesktop,\n    currentSearchParams,\n    setOfficeKey\n  ])\n\n  return { config, status }\n}\n\nexport default useConfig\n"
  },
  {
    "path": "src/modules/views/OnlyOffice/useCreateFile.jsx",
    "content": "import { useEffect, useState, useMemo } from 'react'\n\nimport { useClient } from 'cozy-client'\nimport { uploadFileWithConflictStrategy } from 'cozy-client/dist/models/file'\nimport { useI18n } from 'twake-i18n'\n\nimport logger from '@/lib/logger'\nimport {\n  makeExtByClass,\n  makeMimeByClass\n} from '@/modules/views/OnlyOffice/helpers'\n\nconst useCreateFile = (folderId, fileClass, driveId = undefined) => {\n  const [status, setStatus] = useState('pending')\n  const [fileId, setFileId] = useState(null)\n  const { t } = useI18n()\n  const client = useClient()\n\n  const fileExt = useMemo(() => makeExtByClass(fileClass), [fileClass])\n  const fileMime = useMemo(() => makeMimeByClass(fileClass), [fileClass])\n  const fileUrl = useMemo(\n    () => `/onlyOffice/${fileClass}.${fileExt}`,\n    [fileClass, fileExt]\n  )\n  const fileName = useMemo(\n    () => t(`OnlyOffice.createFileName.${fileClass}`) + `.${fileExt}`,\n    [t, fileClass, fileExt]\n  )\n\n  useEffect(() => {\n    const doCreate = async () => {\n      const reader = new FileReader()\n      reader.onloadend = async () => {\n        try {\n          const { data: createdFile } = await uploadFileWithConflictStrategy(\n            client,\n            reader.result,\n            {\n              name: fileName,\n              dirId: folderId,\n              conflictStrategy: 'rename',\n              driveId,\n              contentType: fileMime\n            }\n          )\n          setStatus('loaded')\n          setFileId(createdFile.id)\n        } catch (error) {\n          logger.error(`Creating Only Office file failed: ${error}`)\n          setStatus('error')\n        }\n      }\n\n      try {\n        const res = await fetch(fileUrl)\n        const data = await res.blob()\n        reader.readAsArrayBuffer(data)\n      } catch (error) {\n        logger.error(`Fetching Only Office template file failed: ${error}`)\n        setStatus('error')\n      }\n    }\n\n    doCreate()\n  }, [fileClass, fileUrl, folderId, fileMime, fileName, driveId, client])\n\n  return { status, fileId }\n}\n\nexport default useCreateFile\n"
  },
  {
    "path": "src/modules/views/Public/PublicFileViewer.jsx",
    "content": "import React, { useMemo, useEffect, useState } from 'react'\nimport { useParams, useNavigate } from 'react-router-dom'\n\nimport Viewer, {\n  FooterActionButtons,\n  ForwardOrDownloadButton\n} from 'cozy-viewer'\n\nimport { FilesViewerLoading } from '@/components/FilesViewerLoading'\nimport useHead from '@/components/useHead'\nimport { useCurrentFolderId } from '@/hooks'\nimport usePublicFilesQuery from '@/modules/views/Public/usePublicFilesQuery'\n\nconst PublicFileViewer = () => {\n  const { fileId } = useParams()\n  const navigate = useNavigate()\n  useHead()\n\n  const [fetchingMore, setFetchingMore] = useState(false)\n\n  const currentFolderId = useCurrentFolderId()\n\n  const filesResult = usePublicFilesQuery(currentFolderId)\n  const viewableFiles = filesResult.data.filter(f => f.type !== 'directory')\n\n  const currentIndex = useMemo(() => {\n    return viewableFiles.findIndex(f => f.id === fileId)\n  }, [viewableFiles, fileId])\n  const hasCurrentIndex = useMemo(() => currentIndex != -1, [currentIndex])\n  const viewerIndex = useMemo(\n    () => (hasCurrentIndex ? currentIndex : 0),\n    [hasCurrentIndex, currentIndex]\n  )\n\n  useEffect(() => {\n    let isMounted = true\n\n    // If we can found the current file but we know there is more file inside the folder\n    const fetchMoreIfNecessary = async () => {\n      if (fetchingMore) {\n        return\n      }\n\n      setFetchingMore(true)\n      try {\n        const currentIndex = viewableFiles.findIndex(f => f.id === fileId)\n\n        if (\n          (currentIndex === -1 ||\n            currentIndex === filesResult.data.length - 1) &&\n          filesResult.hasMore &&\n          isMounted\n        ) {\n          await filesResult.fetchMore()\n        }\n      } finally {\n        setFetchingMore(false)\n      }\n    }\n\n    fetchMoreIfNecessary()\n\n    return () => {\n      isMounted = false\n    }\n  }, [fetchingMore, filesResult, fileId, viewableFiles])\n\n  const handleChange = ({ _id }) => {\n    navigate(`../${_id}`, {\n      relative: 'path'\n    })\n  }\n\n  const handleClose = () => {\n    navigate('..')\n  }\n\n  // If we can't find the file, we fallback to the (potentially loading)\n  // direct stat made by the viewer\n  if (currentIndex === -1) {\n    return <FilesViewerLoading />\n  }\n\n  return (\n    <Viewer\n      files={viewableFiles}\n      currentIndex={viewerIndex}\n      isPublic={true}\n      onChangeRequest={handleChange}\n      onCloseRequest={handleClose}\n      componentsProps={{\n        toolbarProps: {\n          hideSummarizeBtn: true\n        }\n      }}\n    >\n      <FooterActionButtons>\n        <ForwardOrDownloadButton />\n      </FooterActionButtons>\n    </Viewer>\n  )\n}\n\nexport { PublicFileViewer }\n"
  },
  {
    "path": "src/modules/views/Public/PublicFolderView.jsx",
    "content": "import React, { useCallback, useContext, useEffect } from 'react'\nimport { useDispatch } from 'react-redux'\nimport { useNavigate, useLocation, Outlet } from 'react-router-dom'\n\nimport { useClient, models } from 'cozy-client'\nimport flag from 'cozy-flags'\nimport {\n  useSharingContext,\n  SharingBannerPlugin,\n  useSharingInfos,\n  OpenSharingLinkFabButton\n} from 'cozy-sharing'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { Content } from 'cozy-ui/transpiled/react/Layout'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport usePublicFilesQuery from './usePublicFilesQuery'\nimport usePublicWritePermissions from './usePublicWritePermissions'\nimport FolderViewBody from '../Folder/FolderViewBody'\nimport FolderViewBreadcrumb from '../Folder/FolderViewBreadcrumb'\nimport FolderViewHeader from '../Folder/FolderViewHeader'\nimport OldFolderViewBreadcrumb from '../Folder/OldFolderViewBreadcrumb'\nimport FolderViewBodyVz from '../Folder/virtualized/FolderViewBody'\n\nimport useHead from '@/components/useHead'\nimport { ROOT_DIR_ID } from '@/constants/config'\nimport { useClipboardContext } from '@/contexts/ClipboardProvider'\nimport { useCurrentFolderId, useDisplayedFolder } from '@/hooks'\nimport { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'\nimport { FabContext } from '@/lib/FabProvider'\nimport { ModalStack, useModalContext } from '@/lib/ModalContext'\nimport { ModalManager } from '@/lib/react-cozy-helpers'\nimport {\n  download,\n  trash,\n  rename,\n  versions,\n  selectAllItems,\n  hr,\n  summariseByAI\n} from '@/modules/actions'\nimport { duplicateTo } from '@/modules/actions/components/duplicateTo'\nimport { moveTo } from '@/modules/actions/components/moveTo'\nimport { personalizeFolder } from '@/modules/actions/components/personalizeFolder'\nimport { fetchFolder } from '@/modules/breadcrumb/utils/fetchFolder'\nimport { makeExtraColumnsNamesFromMedia } from '@/modules/certifications'\nimport { useExtraColumns } from '@/modules/certifications/useExtraColumns'\nimport AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'\nimport FabWithAddMenuContext from '@/modules/drive/FabWithAddMenuContext'\nimport Main from '@/modules/layout/Main'\nimport PublicToolbar from '@/modules/public/PublicToolbar'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport Dropzone from '@/modules/upload/Dropzone'\nimport DropzoneDnD from '@/modules/upload/DropzoneDnD'\n\nconst fetchParentFolder = async ({ client, folderId }) => {\n  try {\n    return await fetchFolder({ client, folderId })\n  } catch (_err) {\n    return null\n  }\n}\n\nconst getBreadcrumbPath = async ({\n  client,\n  displayedFolder,\n  sharedDocumentId\n}) => {\n  if (!displayedFolder) return\n  const returnPath = [{ id: displayedFolder?.id, name: displayedFolder?.name }]\n  let folder = displayedFolder\n  while (folder && folder.id !== sharedDocumentId) {\n    folder = await fetchParentFolder({ client, folderId: folder?.dir_id })\n    if (folder) {\n      returnPath.unshift({ id: folder?.id, name: folder?.name })\n    }\n  }\n  return returnPath\n}\n\nconst desktopExtraColumnsNames = ['carbonCopy', 'electronicSafe']\nconst mobileExtraColumnsNames = []\n\nconst PublicFolderView = ({ sharedDocumentId }) => {\n  const navigate = useNavigate()\n  const { pathname, state } = useLocation()\n  const client = useClient()\n  const { t, lang } = useI18n()\n  const { isMobile, isDesktop } = useBreakpoints()\n  const { isFabDisplayed, setIsFabDisplayed } = useContext(FabContext)\n  const currentFolderId = useCurrentFolderId()\n  const { displayedFolder } = useDisplayedFolder()\n  const { isSelectionBarVisible, toggleSelectAllItems, isSelectAll } =\n    useSelectionContext()\n  const { hasWritePermissions } = usePublicWritePermissions()\n  const { pushModal, popModal } = useModalContext()\n  const { refresh, isOwner, byDocId } = useSharingContext()\n  const dispatch = useDispatch()\n  const sharingInfos = useSharingInfos()\n  const { showAlert } = useAlert()\n  const isOnSharedFolder =\n    !sharingInfos.loading &&\n    sharingInfos.sharing?.rules?.some(rule =>\n      rule.values.includes(currentFolderId)\n    )\n  useHead()\n  const { hasClipboardData } = useClipboardContext()\n\n  const filesResult = usePublicFilesQuery(currentFolderId)\n  const files = filesResult.data\n\n  const extraColumnsNames = makeExtraColumnsNamesFromMedia({\n    isMobile,\n    desktopExtraColumnsNames,\n    mobileExtraColumnsNames\n  })\n\n  const extraColumns = useExtraColumns({\n    columnsNames: extraColumnsNames,\n    conditionBuilder: ({ files, attribute }) =>\n      files.some(file => models.file.hasMetadataAttribute({ file, attribute })),\n    files\n  })\n\n  // We don't have enough permissions to rely on the realtime notifications or on a cozy-client query to update the view when something changes, so we relaod the view instead\n  const refreshFolderContent = useCallback(\n    () => filesResult.forceRefetch(),\n    [filesResult]\n  )\n\n  const refreshAfterChange = () => {\n    refresh()\n    refreshFolderContent()\n  }\n\n  useKeyboardShortcuts({\n    onPaste: refreshAfterChange,\n    canPaste: hasWritePermissions && hasClipboardData,\n    client,\n    items: filesResult.data,\n    sharingContext: null,\n    allowCopy: hasWritePermissions,\n    allowCut: hasWritePermissions,\n    allowDelete: hasWritePermissions,\n    isPublic: true,\n    pushModal,\n    popModal,\n    refresh: refreshAfterChange\n  })\n\n  useEffect(() => {\n    if (state?.refresh === true) {\n      refreshFolderContent()\n      // Clear the state to prevent repeated refreshes\n      navigate(pathname, { replace: true, state: null })\n    }\n  }, [state, refreshFolderContent, navigate, pathname])\n\n  const actionOptions = {\n    client,\n    t,\n    lang,\n    pushModal,\n    popModal,\n    refresh: refreshAfterChange,\n    dispatch,\n    navigate,\n    showAlert,\n    pathname,\n    hasWriteAccess: hasWritePermissions,\n    canMove: hasWritePermissions,\n    canDuplicate: hasWritePermissions,\n    isPublic: true,\n    isOwner,\n    byDocId,\n    selectAll: () => toggleSelectAllItems(filesResult.data),\n    isSelectAll,\n    isMobile,\n    displayedFolder,\n    onClose: () => {\n      refreshAfterChange()\n    }\n  }\n  const actions = makeActions(\n    [\n      selectAllItems,\n      download,\n      hr,\n      summariseByAI,\n      hr,\n      moveTo,\n      duplicateTo,\n      hr,\n      rename,\n      personalizeFolder,\n      versions,\n      hr,\n      trash\n    ],\n    actionOptions\n  )\n\n  const rootBreadcrumbPath = {\n    id: ROOT_DIR_ID,\n    name: 'Public'\n  }\n\n  useEffect(() => {\n    if (hasWritePermissions) {\n      setIsFabDisplayed(!isDesktop)\n      return () => {\n        // to not have this set to false on other views after using this view\n        setIsFabDisplayed(false)\n      }\n    }\n  }, [setIsFabDisplayed, isDesktop, hasWritePermissions])\n\n  const showNewBreadcrumbFlag = flag(\n    'drive.breadcrumb.showCompleteBreadcrumbOnPublicPage'\n  )\n  const isOldBreadcrumb =\n    !showNewBreadcrumbFlag || showNewBreadcrumbFlag !== true\n\n  // Check if the sharing shortcut has already been created (but not synced)\n  const isShareNotAdded =\n    !sharingInfos.loading && !sharingInfos.isSharingShortcutCreated\n  // Check if you are sharing Cozy to Cozy (Link sharing is on the `/public` route)\n  const isPreview = window.location.pathname === '/preview'\n  // Show the sharing banner plugin only on shared links view and cozy to cozy sharing view(not added)\n  const isSharingBannerPluginDisplayed =\n    isShareNotAdded || (isOnSharedFolder && !isPreview)\n\n  const isAddToMyCozyFabDisplayed = isMobile && isPreview && isShareNotAdded\n\n  const DropzoneComp =\n    flag('drive.virtualization.enabled') && !isMobile ? DropzoneDnD : Dropzone\n\n  return (\n    <Main isPublic={true}>\n      <ModalStack />\n      <ModalManager />\n      {isSharingBannerPluginDisplayed && <SharingBannerPlugin />}\n      <Content className={isMobile ? '' : 'u-ml-1 u-pt-1'}>\n        <DropzoneComp\n          disabled={!hasWritePermissions}\n          displayedFolder={displayedFolder}\n          refreshFolderContent={refreshFolderContent}\n        >\n          <FolderViewHeader>\n            {currentFolderId && (\n              <>\n                {isOldBreadcrumb ? (\n                  <OldFolderViewBreadcrumb\n                    displayedFolder={displayedFolder}\n                    sharedDocumentId={sharedDocumentId}\n                    getBreadcrumbPath={getBreadcrumbPath}\n                  />\n                ) : (\n                  <FolderViewBreadcrumb\n                    rootBreadcrumbPath={rootBreadcrumbPath}\n                    currentFolderId={currentFolderId}\n                  />\n                )}\n                <PublicToolbar\n                  files={files}\n                  hasWriteAccess={hasWritePermissions}\n                  refreshFolderContent={refreshFolderContent}\n                  sharingInfos={sharingInfos}\n                />\n              </>\n            )}\n          </FolderViewHeader>\n          {flag('drive.virtualization.enabled') && !isMobile ? (\n            <FolderViewBodyVz\n              actions={actions}\n              queryResults={[filesResult]}\n              currentFolderId={currentFolderId}\n              displayedFolder={displayedFolder}\n              canDrag\n              canUpload={hasWritePermissions}\n              refreshFolderContent={refreshFolderContent}\n            />\n          ) : (\n            <FolderViewBody\n              actions={actions}\n              queryResults={[filesResult]}\n              canSort={false}\n              currentFolderId={currentFolderId}\n              refreshFolderContent={refreshFolderContent}\n              canUpload={hasWritePermissions}\n              extraColumns={extraColumns}\n              isPublic={true}\n            />\n          )}\n          {isFabDisplayed && (\n            <AddMenuProvider\n              componentsProps={{\n                AddMenu: {\n                  anchorOrigin: {\n                    vertical: 'top',\n                    horizontal: 'left'\n                  }\n                }\n              }}\n              canCreateFolder={hasWritePermissions}\n              canUpload={hasWritePermissions}\n              refreshFolderContent={refreshFolderContent}\n              isPublic={true}\n              displayedFolder={displayedFolder}\n              isSelectionBarVisible={isSelectionBarVisible}\n            >\n              <FabWithAddMenuContext noSidebar={true} />\n            </AddMenuProvider>\n          )}\n          {isAddToMyCozyFabDisplayed && (\n            <OpenSharingLinkFabButton link={sharingInfos.addSharingLink} />\n          )}\n        </DropzoneComp>\n        <Outlet />\n      </Content>\n    </Main>\n  )\n}\n\nexport { PublicFolderView }\n"
  },
  {
    "path": "src/modules/views/Public/PublicFolderView.spec.jsx",
    "content": "import { render, screen } from '@testing-library/react'\nimport React from 'react'\n\nimport { useSharingContext } from 'cozy-sharing'\n\nimport { PublicFolderView } from './PublicFolderView'\nimport usePublicFilesQuery from './usePublicFilesQuery'\nimport { generateFileFixtures, getByTextWithMarkup } from '../testUtils'\nimport AppLike from 'test/components/AppLike'\nimport { setupStoreAndClient } from 'test/setup'\n\njest.mock('cozy-client/dist/hooks/useCapabilities', () =>\n  jest.fn().mockReturnValue({ capabilities: {} })\n)\n\nconst mockNavigate = jest.fn()\nconst mockUseLocation = jest.fn()\n\njest.mock('react-router-dom', () => ({\n  ...jest.requireActual('react-router-dom'),\n  useNavigate: () => mockNavigate,\n  useLocation: () => mockUseLocation()\n}))\n\njest.mock('cozy-intent', () => ({\n  WebviewIntentProvider: ({ children }) => children,\n  useWebviewIntent: () => ({ call: () => {} })\n}))\n\njest.mock('cozy-flags', () => () => true)\n\n// Mock VirtualizedTable to render items in tests\njest.mock('cozy-ui/transpiled/react/Table/Virtualized', () => {\n  const React = require('react')\n  return React.forwardRef(({ rows, data }, ref) => {\n    const items = data || rows || []\n    return (\n      <div data-testid=\"virtuoso-table-dnd\" ref={ref}>\n        {items?.map((row, index) => (\n          <div key={row._id || row.id || index} className=\"fil-content-row\">\n            <span>{row.name}</span>\n          </div>\n        ))}\n      </div>\n    )\n  })\n})\n\n// Remove the FolderViewBody mock - let the real component run with mocked VirtuosoTableDnd\n\njest.mock(\n  '../Folder/FolderViewBreadcrumb',\n  () =>\n    ({ rootBreadcrumbPath, currentFolderId }) => (\n      <div\n        data-path={rootBreadcrumbPath}\n        data-folder-id={currentFolderId}\n        data-testid=\"FolderViewBreadcrumb\"\n      />\n    )\n)\njest.mock('cozy-sharing', () => ({\n  ...jest.requireActual('cozy-sharing'),\n  useSharingContext: jest.fn()\n}))\njest.mock('hooks', () => ({\n  useCurrentFolderId: jest.fn().mockReturnValue('1234'),\n  useDisplayedFolder: jest.fn().mockReturnValue({\n    dir_id: 'parent-folder-id',\n    _id: 'displayed-folder-id',\n    name: 'My Folder'\n  }),\n  useParentFolder: jest.fn().mockReturnValue('5678'),\n  useFolderSort: jest.fn(() => [{ attribute: 'name', order: 'asc' }, jest.fn()])\n}))\n\njest.mock('./usePublicFilesQuery', () => {\n  return jest.fn()\n})\njest.mock('./usePublicWritePermissions', () => jest.fn().mockReturnValue(false))\njest.mock('cozy-keys-lib', () => ({\n  useVaultClient: jest.fn()\n}))\njest.mock('components/pushClient', () => ({\n  isMacOS: jest.fn(() => false),\n  isIOS: jest.fn(() => false),\n  isLinux: jest.fn(() => false),\n  isAndroid: jest.fn(() => false)\n}))\n\nuseSharingContext.mockReturnValue({ byDocId: [] })\n\ndescribe('Public View', () => {\n  const setup = () => {\n    const { store, client } = setupStoreAndClient()\n    client.plugins.realtime = {\n      subscribe: jest.fn(),\n      unsubscribe: jest.fn()\n    }\n    client.query = jest.fn().mockReturnValue({ data: [] })\n\n    return render(\n      <AppLike client={client} store={store}>\n        <PublicFolderView />\n      </AppLike>\n    )\n  }\n\n  // Set default mock return value for useLocation\n  beforeEach(() => {\n    mockUseLocation.mockReturnValue({\n      pathname: '/folder/123',\n      search: '',\n      state: null\n    })\n  })\n\n  const updated_at = '2020-05-14T10:33:31.365224+02:00'\n\n  beforeEach(() => {\n    const nbFiles = 2\n    const path = '/test'\n    const dir_id = 'dirIdParent'\n    const filesFixture = generateFileFixtures({\n      nbFiles,\n      path,\n      dir_id,\n      updated_at\n    })\n    usePublicFilesQuery.mockReturnValue({\n      data: filesFixture,\n      fetchStatus: 'loaded',\n      refreshFolderContent: jest.fn(),\n      hasMore: false,\n      fetchMore: jest.fn()\n    })\n\n    useSharingContext.mockReturnValue({\n      byDocId: filesFixture.reduce((acc, file) => {\n        acc[file._id] = []\n        return acc\n      }, {})\n    })\n  })\n\n  it('renders the public view', async () => {\n    // TODO : Fix https://github.com/cozy/cozy-drive/issues/2913\n    jest.spyOn(console, 'warn').mockImplementation()\n    jest.spyOn(console, 'error').mockImplementation()\n    jest.spyOn(console, 'log').mockImplementation()\n    const { container } = setup()\n\n    // Get the HTMLElement containing the filename if exist. If not throw\n    const el0 = await screen.findByText(`foobar0.pdf`)\n    expect(el0).toBeTruthy()\n\n    // Check if the filename is displayed with the extension. If not throw\n    getByTextWithMarkup(screen.getByText, `foobar0.pdf`)\n\n    const virtuosoTable = container.querySelector(\n      '[data-testid=\"virtuoso-table-dnd\"]'\n    )\n    expect(virtuosoTable).toBeTruthy()\n\n    const fileRows = container.querySelectorAll('.fil-content-row')\n    expect(fileRows.length).toBeGreaterThan(0)\n  })\n\n  it('should use FolderViewBreadcrumb with correct rootBreadcrumbPath', async () => {\n    // When\n    setup()\n\n    // Then\n    expect(screen.getByTestId('FolderViewBreadcrumb')).toBeTruthy()\n    expect(\n      screen.getByTestId('FolderViewBreadcrumb').hasAttribute('data-path')\n    ).toEqual(true)\n    expect(\n      screen.getByTestId('FolderViewBreadcrumb').getAttribute('data-folder-id')\n    ).toEqual('1234')\n  })\n\n  describe('Refresh functionality after move/copy operations', () => {\n    let mockForceRefetch\n\n    beforeEach(() => {\n      mockForceRefetch = jest.fn()\n      usePublicFilesQuery.mockReturnValue({\n        data: generateFileFixtures({\n          nbFiles: 2,\n          path: '/test',\n          dir_id: 'dirIdParent',\n          updated_at: '2020-05-14T10:33:31.365224+02:00'\n        }),\n        fetchStatus: 'loaded',\n        forceRefetch: mockForceRefetch,\n        hasMore: false,\n        fetchMore: jest.fn()\n      })\n\n      // Reset mocks\n      mockNavigate.mockClear()\n      mockUseLocation.mockClear()\n    })\n\n    it('should refresh folder content when navigation state contains refresh=true', async () => {\n      // Given\n      mockUseLocation.mockReturnValue({\n        pathname: '/folder/123',\n        search: '',\n        state: { refresh: true }\n      })\n\n      const { store, client } = setupStoreAndClient()\n      client.plugins.realtime = {\n        subscribe: jest.fn(),\n        unsubscribe: jest.fn()\n      }\n      client.query = jest.fn().mockReturnValue({ data: [] })\n\n      // Mock console methods to suppress logs during test\n      jest.spyOn(console, 'warn').mockImplementation()\n      jest.spyOn(console, 'error').mockImplementation()\n      jest.spyOn(console, 'log').mockImplementation()\n\n      // When - render with navigation state containing refresh signal\n      render(\n        <AppLike client={client} store={store}>\n          <PublicFolderView />\n        </AppLike>\n      )\n\n      // Then - forceRefetch should be called\n      expect(mockForceRefetch).toHaveBeenCalledTimes(1)\n    })\n\n    it('should not refresh folder content when navigation state does not contain refresh signal', async () => {\n      // Given\n      mockUseLocation.mockReturnValue({\n        pathname: '/folder/123',\n        search: '',\n        state: null\n      })\n\n      const { store, client } = setupStoreAndClient()\n      client.plugins.realtime = {\n        subscribe: jest.fn(),\n        unsubscribe: jest.fn()\n      }\n      client.query = jest.fn().mockReturnValue({ data: [] })\n\n      // Mock console methods to suppress logs during test\n      jest.spyOn(console, 'warn').mockImplementation()\n      jest.spyOn(console, 'error').mockImplementation()\n      jest.spyOn(console, 'log').mockImplementation()\n\n      // When - render without refresh signal in navigation state\n      render(\n        <AppLike client={client} store={store}>\n          <PublicFolderView />\n        </AppLike>\n      )\n\n      // Then - forceRefetch should not be called\n      expect(mockForceRefetch).not.toHaveBeenCalled()\n    })\n\n    it('should clear navigation state after refreshing to prevent repeated refreshes', async () => {\n      // Given\n      mockUseLocation.mockReturnValue({\n        pathname: '/folder/123',\n        search: '',\n        state: { refresh: true }\n      })\n\n      const { store, client } = setupStoreAndClient()\n      client.plugins.realtime = {\n        subscribe: jest.fn(),\n        unsubscribe: jest.fn()\n      }\n      client.query = jest.fn().mockReturnValue({ data: [] })\n\n      // Mock console methods to suppress logs during test\n      jest.spyOn(console, 'warn').mockImplementation()\n      jest.spyOn(console, 'error').mockImplementation()\n      jest.spyOn(console, 'log').mockImplementation()\n\n      // When - render with navigation state containing refresh signal\n      render(\n        <AppLike client={client} store={store}>\n          <PublicFolderView />\n        </AppLike>\n      )\n\n      // Then - navigate should be called to clear the state\n      expect(mockNavigate).toHaveBeenCalledWith('/folder/123', {\n        replace: true,\n        state: null\n      })\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Public/usePublicFileByIdsQuery.spec.jsx",
    "content": "/* eslint-disable no-console */\nimport { renderHook } from '@testing-library/react'\n\nimport { useClient } from 'cozy-client'\n\n// Suppress React act warnings for this test file\nconst originalError = console.error\nbeforeAll(() => {\n  console.error = (...args) => {\n    if (\n      typeof args[0] === 'string' &&\n      args[0].includes(\n        'Warning: An update to %s inside a test was not wrapped in act'\n      )\n    ) {\n      return\n    }\n    originalError.call(console, ...args)\n  }\n})\n\nafterAll(() => {\n  console.error = originalError\n})\n\nimport {\n  fetchFileById,\n  usePublicFileByIdsQuery\n} from './usePublicFileByIdsQuery'\n\n// Mock cozy-client\njest.mock('cozy-client', () => ({\n  useClient: jest.fn()\n}))\n\nconst mockClient = {\n  collection: jest.fn()\n}\n\nconst mockFiles = [\n  {\n    _id: 'file1',\n    _type: 'io.cozy.files',\n    name: 'document1.pdf',\n    type: 'file',\n    mime: 'application/pdf',\n    size: 1024\n  },\n  {\n    _id: 'file2',\n    _type: 'io.cozy.files',\n    name: 'document2.pdf',\n    type: 'file',\n    mime: 'application/pdf',\n    size: 2048\n  }\n]\n\ndescribe('fetchFileById', () => {\n  beforeEach(() => {\n    jest.clearAllMocks()\n  })\n\n  it('should fetch file by id successfully', async () => {\n    const mockStatById = jest.fn().mockResolvedValue({\n      data: [mockFiles[0]]\n    })\n\n    mockClient.collection.mockReturnValue({\n      statById: mockStatById\n    })\n\n    const result = await fetchFileById(mockClient, 'file1')\n\n    expect(mockClient.collection).toHaveBeenCalledWith('io.cozy.files')\n    expect(mockStatById).toHaveBeenCalledWith('file1')\n    expect(result).toEqual([mockFiles[0]])\n  })\n\n  it('should handle errors when fetching file', async () => {\n    const mockStatById = jest.fn().mockRejectedValue(new Error('Network error'))\n\n    mockClient.collection.mockReturnValue({\n      statById: mockStatById\n    })\n\n    await expect(fetchFileById(mockClient, 'file1')).rejects.toThrow(\n      'Network error'\n    )\n  })\n})\n\ndescribe('usePublicFileByIdsQuery', () => {\n  beforeEach(() => {\n    jest.clearAllMocks()\n    useClient.mockReturnValue(mockClient)\n    // Reset mock client\n    mockClient.collection.mockReturnValue({\n      statById: jest.fn().mockResolvedValue({ data: [] })\n    })\n  })\n\n  it('should return initial state', () => {\n    const { result } = renderHook(() => usePublicFileByIdsQuery(['file1']))\n\n    expect(result.current.fetchStatus).toBe('loading')\n    expect(result.current.files).toEqual([])\n  })\n\n  it('should fetch files successfully', () => {\n    const mockStatById = jest\n      .fn()\n      .mockResolvedValueOnce({ data: [mockFiles[0]] })\n      .mockResolvedValueOnce({ data: [mockFiles[1]] })\n\n    // Clear previous calls and set up fresh mock\n    jest.clearAllMocks()\n    mockClient.collection.mockReturnValue({\n      statById: mockStatById\n    })\n\n    const { result } = renderHook(() =>\n      usePublicFileByIdsQuery(['file1', 'file2'])\n    )\n\n    expect(result.current.fetchStatus).toBe('loading')\n    expect(result.current.files).toEqual([])\n    // Verify the function calls were made (may be called multiple times due to React)\n    expect(mockStatById).toHaveBeenCalledWith('file1')\n    expect(mockStatById).toHaveBeenCalledWith('file2')\n  })\n\n  it('should handle empty file ids array', () => {\n    // Clear previous calls\n    jest.clearAllMocks()\n\n    const { result } = renderHook(() => usePublicFileByIdsQuery([]))\n\n    expect(result.current.fetchStatus).toBe('loading')\n    expect(result.current.files).toEqual([])\n    // For empty array, no fetchFileById calls are made, so collection is not called\n    expect(mockClient.collection).not.toHaveBeenCalled()\n  })\n\n  it('should handle fetch errors', () => {\n    const mockStatById = jest.fn().mockRejectedValue(new Error('Network error'))\n\n    mockClient.collection.mockReturnValue({\n      statById: mockStatById\n    })\n\n    const { result } = renderHook(() => usePublicFileByIdsQuery(['file1']))\n\n    expect(result.current.fetchStatus).toBe('loading')\n    expect(result.current.files).toEqual([])\n  })\n\n  it('should handle null client', () => {\n    useClient.mockReturnValue(null)\n\n    const { result } = renderHook(() => usePublicFileByIdsQuery(['file1']))\n\n    expect(result.current.fetchStatus).toBe('pending')\n    expect(result.current.files).toEqual([])\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Public/usePublicFileByIdsQuery.tsx",
    "content": "import { useState, useEffect } from 'react'\n\nimport CozyClient, { useClient } from 'cozy-client'\nimport { IOCozyFile } from 'cozy-client/types/types'\n\ntype FetchStatus = 'pending' | 'loading' | 'loaded' | 'error'\n\ninterface UsePublicFileByIdsQueryReturn {\n  fetchStatus: FetchStatus\n  files: IOCozyFile[]\n}\n\ninterface FileCollection {\n  statById: (fileId: string) => Promise<{ data: IOCozyFile[] }>\n}\n\nexport const fetchFileById = async (\n  client: CozyClient,\n  fileId: string\n): Promise<IOCozyFile[]> => {\n  const response = await (\n    client.collection('io.cozy.files') as FileCollection\n  ).statById(fileId)\n\n  return response.data\n}\n\nexport const usePublicFileByIdsQuery = (\n  fileIds: string[]\n): UsePublicFileByIdsQueryReturn => {\n  const client = useClient()\n  const [fetchStatus, setFetchStatus] = useState<FetchStatus>('pending')\n  const [data, setData] = useState<IOCozyFile[]>([])\n\n  useEffect(() => {\n    if (!client) return\n\n    const initialFetch = async (): Promise<void> => {\n      try {\n        setFetchStatus('loading')\n\n        const response = await Promise.all(\n          fileIds.map(fileId => fetchFileById(client, fileId))\n        )\n\n        const parsedData = response.flatMap(item => item)\n        setData(parsedData)\n        setFetchStatus('loaded')\n      } catch (_error) {\n        setFetchStatus('error')\n      }\n    }\n    void initialFetch()\n  }, [client, fileIds])\n\n  return {\n    fetchStatus,\n    files: data\n  }\n}\n\nexport default usePublicFileByIdsQuery\n"
  },
  {
    "path": "src/modules/views/Public/usePublicFilesQuery.jsx",
    "content": "import get from 'lodash/get'\nimport { useState, useEffect, useRef } from 'react'\n\nimport { useClient } from 'cozy-client'\n\nconst statById = async (client, folderId, cursorToUse) => {\n  // Most stack routes are off-limit when we have a read-only token, so we use a simple GET to load the folder content.\n  // no query because we need to paginate the included files\n  const { included, links } = await client\n    .collection('io.cozy.files')\n    .statById(folderId, {\n      'page[cursor]': cursorToUse\n    })\n\n  const nextRelativeLink = get(links, 'next', '')\n  const dummyURL = 'http://example.com' // we're only interested in the query string, the base url doesn't matter\n  const nextAbsoluteLinkURL = new URL(`${dummyURL}${nextRelativeLink}`)\n  const cursor = nextAbsoluteLinkURL.searchParams.get('page[cursor]')\n\n  return { included, cursor }\n}\n\nexport const usePublicFilesQuery = currentFolderId => {\n  const client = useClient()\n  const [fetchStatus, setFetchStatus] = useState('pending')\n  const [data, setData] = useState([])\n  const [hasMore, setHasMore] = useState(false)\n\n  const [fetchCounter, updateFetchCounter] = useState(1)\n  const forceRefetch = () => updateFetchCounter(prev => prev + 1)\n\n  const nextCursor = useRef(null)\n  const isFetching = useRef(false)\n\n  useEffect(() => {\n    const initialFetch = async () => {\n      try {\n        setFetchStatus('loading')\n        const { included, cursor } = await statById(client, currentFolderId)\n        nextCursor.current = cursor\n        setData(included || [])\n        setHasMore(!!cursor)\n        setFetchStatus('loaded')\n      } catch (_error) {\n        setFetchStatus('error')\n      }\n    }\n    initialFetch()\n  }, [currentFolderId, fetchCounter, client])\n\n  const fetchMore = async () => {\n    if (isFetching.current) return\n    isFetching.current = true\n    try {\n      const { included, cursor } = await statById(\n        client,\n        currentFolderId,\n        nextCursor.current\n      )\n      const safeIncluded = included || []\n      setData(prevData => [...prevData, ...safeIncluded])\n      setHasMore(!!cursor)\n      nextCursor.current = cursor\n      setFetchStatus('loaded')\n    } catch (_error) {\n      setFetchStatus('error')\n    } finally {\n      isFetching.current = false\n    }\n  }\n\n  return {\n    fetchStatus,\n    data,\n    forceRefetch,\n    hasMore,\n    fetchMore\n  }\n}\n\nexport default usePublicFilesQuery\n"
  },
  {
    "path": "src/modules/views/Public/usePublicFilesQuery.spec.jsx",
    "content": "import { renderHook, act, waitFor } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\n\nimport usePublicFilesQuery from './usePublicFilesQuery'\nimport AppLike from 'test/components/AppLike'\n\ndescribe('usePublicFilesQuery', () => {\n  const mockClient = createMockClient({})\n  const statByIdMock = jest.fn()\n\n  const mockFolderId = 'folder-id-1'\n  const mockData = [{ id: 1 }, { id: 2 }]\n\n  mockClient.collection = () => ({\n    statById: statByIdMock\n  })\n\n  const setup = () => {\n    const wrapper = ({ children }) => (\n      <AppLike client={mockClient}>{children}</AppLike>\n    )\n\n    return renderHook(() => usePublicFilesQuery(mockFolderId), {\n      wrapper\n    })\n  }\n\n  it('makes data available', async () => {\n    statByIdMock.mockResolvedValue({\n      included: mockData,\n      links: {}\n    })\n    const { result } = setup()\n\n    expect(result.current.data).toEqual([])\n    expect(result.current.fetchStatus).toEqual('loading')\n    expect(result.current.hasMore).toBe(false)\n\n    await waitFor(() => expect(result.current.fetchStatus).toEqual('loaded'))\n\n    expect(result.current.data).toEqual(mockData)\n  })\n\n  it('works even without included files', async () => {\n    statByIdMock.mockResolvedValue({\n      links: {}\n    })\n    const { result } = setup()\n\n    expect(result.current.data).toEqual([])\n    expect(result.current.fetchStatus).toEqual('loading')\n    expect(result.current.hasMore).toBe(false)\n\n    await waitFor(() => expect(result.current.fetchStatus).toEqual('loaded'))\n\n    expect(result.current.data).toEqual([])\n  })\n\n  it('paginate results', async () => {\n    const nextPageData = [{ id: 3 }, { id: 4 }]\n    const cursor = '123abc'\n    statByIdMock.mockResolvedValueOnce({\n      included: mockData,\n      links: {\n        next: `/relative/link?page[cursor]=${cursor}&cursor=no&other=alsono`\n      }\n    })\n    const { result } = setup()\n\n    await waitFor(() => expect(result.current.fetchStatus).toEqual('loaded'))\n\n    expect(result.current.data).toEqual(mockData)\n    expect(result.current.hasMore).toEqual(true)\n\n    statByIdMock.mockResolvedValueOnce({\n      included: nextPageData,\n      links: {}\n    })\n\n    await act(() => result.current.fetchMore())\n\n    expect(statByIdMock).toHaveBeenCalledWith(mockFolderId, {\n      'page[cursor]': cursor\n    })\n    expect(result.current.fetchStatus).toEqual('loaded')\n    expect(result.current.data).toEqual(mockData.concat(nextPageData))\n    expect(result.current.hasMore).toEqual(false)\n  })\n\n  it('refetches the initial data', async () => {\n    statByIdMock.mockResolvedValue({\n      included: mockData,\n      links: {\n        next: `/relative/link?page[cursor]=some-cursor&cursor=no&other=alsono`\n      }\n    })\n    const { result } = setup()\n\n    await waitFor(() => expect(result.current.fetchStatus).toEqual('loaded'))\n\n    expect(result.current.data).toEqual(mockData)\n    expect(result.current.hasMore).toEqual(true)\n\n    act(() => result.current.forceRefetch())\n\n    expect(result.current.fetchStatus).toEqual('loading')\n\n    await waitFor(() => expect(result.current.fetchStatus).toEqual('loaded'))\n    expect(statByIdMock).toHaveBeenCalledWith(mockFolderId, {\n      'page[cursor]': undefined\n    })\n    expect(result.current.fetchStatus).toEqual('loaded')\n    expect(result.current.data).toEqual(mockData)\n    expect(result.current.hasMore).toEqual(true)\n  })\n\n  it('reports error', async () => {\n    statByIdMock.mockRejectedValue()\n    const { result } = setup()\n\n    await waitFor(() => expect(result.current.fetchStatus).toEqual('error'))\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Public/usePublicWritePermissions.jsx",
    "content": "import { useState, useEffect } from 'react'\n\nimport { useClient, models } from 'cozy-client'\n\nexport const usePublicWritePermissions = currentFolderId => {\n  const client = useClient()\n  const [fetchStatus, setFetchStatus] = useState('pending')\n  const [hasWritePermissions, setHasWritePermissions] = useState(false)\n\n  useEffect(() => {\n    const fetch = async () => {\n      try {\n        setFetchStatus('loading')\n        const permissions = await models.permission.fetchOwn(client)\n        setHasWritePermissions(!models.permission.isReadOnly(permissions[0]))\n        setFetchStatus('loaded')\n      } catch (_error) {\n        setFetchStatus('error')\n      }\n    }\n    fetch()\n  }, [client, currentFolderId])\n\n  return { fetchStatus, hasWritePermissions }\n}\n\nexport default usePublicWritePermissions\n"
  },
  {
    "path": "src/modules/views/Recent/FilesViewerRecent.jsx",
    "content": "import React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useQuery } from 'cozy-client'\n\nimport { FilesViewerLoading } from '@/components/FilesViewerLoading'\nimport useHead from '@/components/useHead'\nimport FilesViewer from '@/modules/viewer/FilesViewer'\nimport { buildRecentQuery } from '@/queries'\n\nconst FilesViewerRecent = () => {\n  const filesQuery = buildRecentQuery()\n  const results = useQuery(filesQuery.definition, filesQuery.options)\n  const navigate = useNavigate()\n  useHead()\n\n  if (results.data) {\n    const viewableFiles = results.data\n    return (\n      <FilesViewer\n        files={viewableFiles}\n        filesQuery={results}\n        onClose={() => navigate('/recent')}\n        onChange={fileId => navigate(`/recent/file/${fileId}`)}\n      />\n    )\n  } else {\n    return <FilesViewerLoading />\n  }\n}\n\nexport default FilesViewerRecent\n"
  },
  {
    "path": "src/modules/views/Recent/index.jsx",
    "content": "import React from 'react'\nimport { useDispatch } from 'react-redux'\nimport { useNavigate, Outlet, useLocation } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport flag from 'cozy-flags'\nimport {\n  useSharingContext,\n  useNativeFileSharing,\n  shareNative\n} from 'cozy-sharing'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { Content } from 'cozy-ui/transpiled/react/Layout'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport FolderView from '../Folder/FolderView'\nimport FolderViewBody from '../Folder/FolderViewBody'\nimport FolderViewHeader from '../Folder/FolderViewHeader'\nimport FolderViewBodyVz from '../Folder/virtualized/FolderViewBody'\n\nimport useHead from '@/components/useHead'\nimport { RECENT_FOLDER_ID } from '@/constants/config'\nimport { useFolderSort } from '@/hooks'\nimport { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'\nimport useRecentFiles from '@/hooks/useRecentFiles'\nimport { useModalContext } from '@/lib/ModalContext'\nimport {\n  download,\n  trash,\n  rename,\n  infos,\n  versions,\n  hr,\n  share,\n  selectAllItems,\n  summariseByAI\n} from '@/modules/actions'\nimport { addToFavorites } from '@/modules/actions/components/addToFavorites'\nimport { moveTo } from '@/modules/actions/components/moveTo'\nimport { removeFromFavorites } from '@/modules/actions/components/removeFromFavorites'\nimport { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'\nimport { makeExtraColumnsNamesFromMedia } from '@/modules/certifications'\nimport { useExtraColumns } from '@/modules/certifications/useExtraColumns'\nimport AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'\nimport FabWithAddMenuContext from '@/modules/drive/FabWithAddMenuContext'\nimport Toolbar from '@/modules/drive/Toolbar'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport { buildRecentWithMetadataAttributeQuery } from '@/queries'\n\nconst desktopExtraColumnsNames = ['carbonCopy', 'electronicSafe']\nconst mobileExtraColumnsNames = []\n\nexport const RecentView = () => {\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n  const { t, lang } = useI18n()\n  const { isMobile } = useBreakpoints()\n  const client = useClient()\n  const { pushModal, popModal } = useModalContext()\n  const { isSelectionBarVisible, toggleSelectAllItems, isSelectAll } =\n    useSelectionContext()\n  const sharingContext = useSharingContext()\n  const { allLoaded, refresh, isOwner, byDocId } = sharingContext\n  const { isNativeFileSharingAvailable, shareFilesNative } =\n    useNativeFileSharing()\n  const dispatch = useDispatch()\n  useHead({ title: t('breadcrumb.title_recent') })\n  const { showAlert } = useAlert()\n  const [sortOrder, setSortOrder, isSettingsLoaded] =\n    useFolderSort(RECENT_FOLDER_ID)\n\n  const extraColumnsNames = makeExtraColumnsNamesFromMedia({\n    isMobile,\n    desktopExtraColumnsNames,\n    mobileExtraColumnsNames\n  })\n\n  const extraColumns = useExtraColumns({\n    columnsNames: extraColumnsNames,\n    queryBuilder: buildRecentWithMetadataAttributeQuery\n  })\n\n  const recentsResult = useRecentFiles()\n\n  useKeyboardShortcuts({\n    client,\n    items: recentsResult?.data || [],\n    sharingContext,\n    allowCopy: false,\n    pushModal,\n    popModal,\n    refresh\n  })\n\n  const actionsOptions = {\n    client,\n    t,\n    lang,\n    pushModal,\n    popModal,\n    refresh,\n    dispatch,\n    navigate,\n    pathname,\n    hasWriteAccess: true,\n    canMove: true,\n    isPublic: false,\n    allLoaded,\n    showAlert,\n    isOwner,\n    byDocId,\n    isMobile,\n    isNativeFileSharingAvailable,\n    shareFilesNative,\n    selectAll: () => toggleSelectAllItems(recentsResult?.data || []),\n    isSelectAll\n  }\n\n  const actions = makeActions(\n    [\n      selectAllItems,\n      share,\n      shareNative,\n      download,\n      hr,\n      summariseByAI,\n      hr,\n      rename,\n      moveTo,\n      addToFavorites,\n      removeFromFavorites,\n      infos,\n      hr,\n      versions,\n      hr,\n      trash\n    ],\n    actionsOptions\n  )\n\n  return (\n    <FolderView>\n      <Content className={isMobile ? '' : 'u-pt-1'}>\n        <FolderViewHeader>\n          <Breadcrumb path={[{ name: t('breadcrumb.title_recent') }]} />\n          <Toolbar canUpload={false} canCreateFolder={false} />\n        </FolderViewHeader>\n        {flag('drive.virtualization.enabled') && !isMobile ? (\n          <FolderViewBodyVz\n            actions={actions}\n            queryResults={[recentsResult]}\n            withFilePath={true}\n            orderProps={{\n              sortOrder,\n              setOrder: setSortOrder,\n              isSettingsLoaded\n            }}\n          />\n        ) : (\n          <FolderViewBody\n            actions={actions}\n            queryResults={[recentsResult]}\n            withFilePath={true}\n            extraColumns={extraColumns}\n          />\n        )}\n        <Outlet />\n        {isMobile && (\n          <AddMenuProvider\n            canCreateFolder={true}\n            canUpload={true}\n            disabled={false}\n            displayedFolder={null}\n            isSelectionBarVisible={isSelectionBarVisible}\n            isPublic={false}\n            refreshFolderContent={() => {}}\n          >\n            <FabWithAddMenuContext noSidebar={false} />\n          </AddMenuProvider>\n        )}\n      </Content>\n    </FolderView>\n  )\n}\n\nexport default RecentView\n"
  },
  {
    "path": "src/modules/views/Recent/index.spec.jsx",
    "content": "import { render, fireEvent, act } from '@testing-library/react'\nimport React from 'react'\n\nimport { useSharingContext } from 'cozy-sharing'\n\nimport RecentViewWithProvider from './index'\nimport {\n  generateFileFixtures,\n  getByTextWithMarkup,\n  removeNonASCII\n} from '../testUtils'\nimport AppLike from 'test/components/AppLike'\nimport { setupStoreAndClient } from 'test/setup'\n\nconst mockNavigate = jest.fn()\n\njest.mock('react-router-dom', () => ({\n  ...jest.requireActual('react-router-dom'),\n  useNavigate: () => mockNavigate\n}))\n\njest.mock('components/pushClient', () => ({\n  isMacOS: jest.fn(() => false),\n  isIOS: jest.fn(() => false),\n  isLinux: jest.fn(() => false),\n  isAndroid: jest.fn(() => false)\n}))\njest.mock('components/pushClient/Banner', () => () => <div>Banner</div>)\njest.mock('cozy-client/dist/hooks/useQuery', () =>\n  jest.fn(() => ({\n    fetchStatus: '',\n    data: []\n  }))\n)\njest.mock('cozy-keys-lib', () => ({\n  useVaultClient: jest.fn()\n}))\n\njest.mock('components/useHead', () => jest.fn())\njest.mock('cozy-sharing', () => ({\n  __esModule: true,\n  ...jest.requireActual('cozy-sharing'),\n  useSharingContext: jest.fn()\n}))\n\njest.mock('cozy-dataproxy-lib', () => ({\n  useDataProxy: jest.fn()\n}))\n\nconst mockUseDataProxy = require('cozy-dataproxy-lib').useDataProxy\n\nuseSharingContext.mockReturnValue({ byDocId: [] })\n\nconst setup = ({ nbFiles, path, dir_id, updated_at }) => {\n  const { store, client } = setupStoreAndClient()\n\n  client.plugins.realtime = {\n    subscribe: jest.fn(),\n    unsubscribe: jest.fn()\n  }\n  client.query = jest.fn().mockReturnValue({ data: [] })\n  client.stackClient.fetchJSON = jest\n    .fn()\n    .mockReturnValue({ data: [], rows: [] })\n\n  const filesFixture = generateFileFixtures({\n    nbFiles,\n    path,\n    dir_id,\n    updated_at\n  })\n\n  const filesWithPath = filesFixture.map(f => ({\n    ...f,\n    displayedPath: path\n  }))\n\n  // Mock useDataProxy to return a dataProxy object\n  mockUseDataProxy.mockReturnValue({\n    dataProxyServicesAvailable: true,\n    recents: jest.fn().mockResolvedValue(filesWithPath)\n  })\n\n  const rendered = render(\n    <AppLike client={client} store={store}>\n      <RecentViewWithProvider />\n    </AppLike>\n  )\n  return { ...rendered, client }\n}\n\ndescribe('Recent View', () => {\n  it('tests the recent view', async () => {\n    // TODO : Remove nested <a> on File when withFilePath is true\n    jest.spyOn(console, 'error').mockImplementation()\n    // TODO : Fix https://github.com/cozy/cozy-drive/issues/2913)\n    jest.spyOn(console, 'warn').mockImplementation()\n    const nbFiles = 2\n    const path = '/test'\n    const dir_id = '123'\n    const updated_at = '2020-05-14T10:33:31.365224+02:00'\n\n    const { getByText } = setup({\n      nbFiles,\n      path,\n      dir_id,\n      updated_at\n    })\n    const sleep = duration =>\n      new Promise(resolve => setTimeout(resolve, duration))\n    await act(async () => {\n      await sleep(100)\n    })\n    // Get the HTMLElement containing the filename if exist. If not throw\n    const el0 = getByText(`foobar0`)\n    // Check if the filename is displayed with the extension. If not throw\n    getByTextWithMarkup(getByText, `foobar0.pdf`)\n    // get the FileRow element\n    const fileRow0 = el0.closest('.fil-content-row')\n    // check if the date is right\n    expect(fileRow0.getElementsByTagName('time')[0].dateTime).toEqual(\n      updated_at\n    )\n    // check the path to the parent's folder\n    const linkElement0 = fileRow0.getElementsByClassName('fil-file-path')[0]\n    expect(removeNonASCII(linkElement0.textContent)).toEqual(path)\n\n    expect(linkElement0.href.endsWith(`#/folder/${dir_id}`)).toBe(true)\n\n    // check if the ActionMenu is displayed\n    fireEvent.click(fileRow0.getElementsByTagName('button')[0])\n    const el1 = getByText(`foobar1`)\n    const parentDiv1 = el1.closest('.fil-file')\n    expect(\n      removeNonASCII(\n        parentDiv1.getElementsByClassName('fil-file-path')[0].textContent\n      )\n    ).toEqual(path)\n\n    // navigates  to the history view\n    const historyItem = getByText('History')\n    fireEvent.click(historyItem)\n    expect(mockNavigate).toHaveBeenCalledWith('/file/file-foobar0/revision')\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Search/SearchView.jsx",
    "content": "import cx from 'classnames'\nimport React, { useState, useCallback } from 'react'\nimport { useEffect } from 'react'\nimport { useLocation, useNavigate } from 'react-router-dom'\n\nimport { BarLeft, BarSearch } from 'cozy-bar'\nimport { models, useClient } from 'cozy-client'\nimport { isFlagshipApp } from 'cozy-device-helper'\nimport { useWebviewIntent } from 'cozy-intent'\nimport { Main } from 'cozy-ui/transpiled/react/Layout'\nimport List from 'cozy-ui/transpiled/react/List'\nimport LoadMore from 'cozy-ui/transpiled/react/LoadMore'\nimport Input from 'cozy-ui/transpiled/react/legacy/Input'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport styles from '@/modules/search/components/styles.styl'\n\nimport BackButton from '@/components/Button/BackButton'\nimport BarSearchInputGroup from '@/modules/search/components/BarSearchInputGroup'\nimport SearchEmpty from '@/modules/search/components/SearchEmpty'\nimport SuggestionItem from '@/modules/search/components/SuggestionItem'\nimport SuggestionListSkeleton from '@/modules/search/components/SuggestionListSkeleton'\nimport useSearch from '@/modules/search/hooks/useSearch'\n\nconst SearchView = () => {\n  const webviewIntent = useWebviewIntent()\n  const { search } = useLocation()\n  const navigate = useNavigate()\n  const { isMobile } = useBreakpoints()\n  const client = useClient()\n\n  const [searchTerm, setSearchTerm] = useState('')\n  const { t } = useI18n()\n  const {\n    isBusy,\n    suggestions,\n    hasSuggestions,\n    query,\n    makeIndexes,\n    hasMore,\n    fetchMore\n  } = useSearch(searchTerm, { limit: 25 })\n\n  useEffect(() => {\n    makeIndexes()\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  const onInputChanged = event => {\n    setSearchTerm(event.target.value)\n  }\n\n  const navigateBack = useCallback(() => {\n    const params = new URLSearchParams(search)\n    const returnPath = params.get('returnPath')\n    navigate(returnPath ? returnPath : '/')\n  }, [navigate, search])\n\n  const openSuggestion = useCallback(\n    async suggestion => {\n      if (suggestion.openOn === 'drive') {\n        navigate(suggestion.url)\n      } else if (suggestion.openOn === 'notes') {\n        const url = await models.note.fetchURL(client, {\n          id: suggestion.url.substr(3)\n        })\n        if (isFlagshipApp()) {\n          webviewIntent.call('openApp', url, {\n            slug: 'notes'\n          })\n        } else {\n          window.location.assign(url)\n        }\n      } else {\n        // eslint-disable-next-line no-console\n        console.error(\n          `openSuggestion (${suggestion.name}) could not be executed`\n        )\n      }\n    },\n    [navigate, webviewIntent, client]\n  )\n\n  const handleCleanInput = () => {\n    setSearchTerm('')\n  }\n\n  const hasNoSearchResult = searchTerm !== '' && !hasSuggestions\n\n  return (\n    <Main>\n      {isMobile && (\n        <BarLeft>\n          <BackButton onClick={navigateBack} />\n        </BarLeft>\n      )}\n      <BarSearch>\n        <div\n          className={cx(\n            styles['bar-search-container'],\n            isMobile ? styles['mobile'] : ''\n          )}\n          role=\"search\"\n        >\n          <BarSearchInputGroup\n            isMobile={isMobile}\n            isInputNotEmpty={searchTerm !== ''}\n            onClean={handleCleanInput}\n          >\n            <Input\n              fullwidth={true}\n              value={searchTerm}\n              onChange={onInputChanged}\n              placeholder={t('searchbar.placeholder')}\n              autoFocus\n            />\n          </BarSearchInputGroup>\n        </div>\n      </BarSearch>\n      {hasSuggestions && (\n        <List>\n          {suggestions.map(suggestion => (\n            <SuggestionItem\n              suggestion={suggestion}\n              query={query}\n              key={suggestion.id}\n              onClick={openSuggestion}\n              isMobile={isMobile}\n            />\n          ))}\n        </List>\n      )}\n      {hasMore && (\n        <div className=\"u-flex u-flex-justify-center\">\n          <LoadMore label={t('table.load_more')} fetchMore={fetchMore} />\n        </div>\n      )}\n      {hasNoSearchResult && !isBusy && <SearchEmpty query={query} />}\n      {hasNoSearchResult && isBusy && <SuggestionListSkeleton count={10} />}\n    </Main>\n  )\n}\n\nexport default SearchView\n"
  },
  {
    "path": "src/modules/views/SharedDrive/CreateSharedDriveButton.jsx",
    "content": "import React from 'react'\nimport { useDispatch } from 'react-redux'\n\nimport { SharedDriveModal } from 'cozy-sharing'\nimport Button from 'cozy-ui/transpiled/react/Buttons'\nimport Icon from 'cozy-ui/transpiled/react/Icon'\nimport PlusIcon from 'cozy-ui/transpiled/react/Icons/Plus'\n\nimport { showModal } from '@/lib/react-cozy-helpers'\n\nconst CreateSharedDriveButton = ({ className, variant, label }) => {\n  const dispatch = useDispatch()\n\n  return (\n    <Button\n      className={className}\n      variant={variant}\n      startIcon={<Icon icon={PlusIcon} />}\n      label={label}\n      onClick={() => dispatch(showModal(<SharedDriveModal />))}\n    />\n  )\n}\n\nexport default CreateSharedDriveButton\n"
  },
  {
    "path": "src/modules/views/SharedDrive/FilesViewerSharedDrive.jsx",
    "content": "import React from 'react'\nimport { useNavigate, useParams } from 'react-router-dom'\n\nimport { useQuery } from 'cozy-client'\nimport { useSharingContext } from 'cozy-sharing'\n\nimport { FilesViewerLoading } from '@/components/FilesViewerLoading'\nimport useHead from '@/components/useHead'\nimport { useCurrentFolderId, useFolderSort } from '@/hooks'\nimport {\n  getSharedDrivePath,\n  getSharedDriveViewerPath\n} from '@/modules/routeUtils'\nimport FilesViewer from '@/modules/viewer/FilesViewer'\nimport { buildSharedDriveQuery } from '@/queries'\n\nconst FilesViewerSharedDrive = () => {\n  const navigate = useNavigate()\n  const [sortOrder] = useFolderSort()\n  const folderId = useCurrentFolderId()\n  const { driveId } = useParams()\n  const { hasWriteAccess } = useSharingContext()\n  useHead()\n\n  const buildedFilesQuery = buildSharedDriveQuery({\n    currentFolderId: folderId,\n    type: 'file',\n    sortAttribute: sortOrder.attribute,\n    sortOrder: sortOrder.order,\n    driveId\n  })\n\n  const filesQuery = useQuery(\n    buildedFilesQuery.definition,\n    buildedFilesQuery.options\n  )\n\n  const viewableFiles = filesQuery.data\n\n  if (viewableFiles) {\n    return (\n      <FilesViewer\n        files={viewableFiles}\n        filesQuery={filesQuery}\n        onClose={() => navigate(getSharedDrivePath(driveId, folderId))}\n        onChange={fileId =>\n          navigate(`${getSharedDriveViewerPath(driveId, folderId, fileId)}`)\n        }\n        viewerProps={{\n          panel: {\n            sharing: { disabled: !hasWriteAccess(folderId, driveId) }\n          }\n        }}\n      />\n    )\n  }\n\n  return <FilesViewerLoading />\n}\n\nexport default FilesViewerSharedDrive\n"
  },
  {
    "path": "src/modules/views/SharedDrive/SharedDriveFolderView.jsx",
    "content": "import React, { useMemo, useContext, useEffect } from 'react'\nimport { useDispatch } from 'react-redux'\nimport { Outlet, useParams, useNavigate, useLocation } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\nimport flag from 'cozy-flags'\nimport { useVaultClient } from 'cozy-keys-lib'\nimport { useSharingContext } from 'cozy-sharing'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport useHead from '@/components/useHead'\nimport { SHARED_DRIVES_DIR_ID } from '@/constants/config'\nimport { useClipboardContext } from '@/contexts/ClipboardProvider'\nimport { useDisplayedFolder, useFolderSort } from '@/hooks'\nimport { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'\nimport { FabContext } from '@/lib/FabProvider'\nimport { useModalContext } from '@/lib/ModalContext'\nimport {\n  download,\n  infos,\n  versions,\n  rename,\n  trash,\n  hr,\n  share\n} from '@/modules/actions'\nimport { duplicateTo } from '@/modules/actions/components/duplicateTo'\nimport { moveTo } from '@/modules/actions/components/moveTo'\nimport { personalizeFolder } from '@/modules/actions/components/personalizeFolder'\nimport AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'\nimport FabWithAddMenuContext from '@/modules/drive/FabWithAddMenuContext'\nimport Toolbar from '@/modules/drive/Toolbar'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport { SharedDriveBreadcrumb } from '@/modules/shareddrives/components/SharedDriveBreadcrumb'\nimport { SharedDriveFolderBody } from '@/modules/shareddrives/components/SharedDriveFolderBody'\nimport { useSharedDriveFolder } from '@/modules/shareddrives/hooks/useSharedDriveFolder'\nimport Dropzone from '@/modules/upload/Dropzone'\nimport DropzoneDnD from '@/modules/upload/DropzoneDnD'\nimport FolderView from '@/modules/views/Folder/FolderView'\nimport FolderViewHeader from '@/modules/views/Folder/FolderViewHeader'\nimport FolderViewBodyVz from '@/modules/views/Folder/virtualized/FolderViewBody'\n\nconst SharedDriveFolderView = () => {\n  const client = useClient()\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n  const { isMobile } = useBreakpoints()\n  const params = useParams()\n  useHead()\n  const { driveId, folderId } = params\n  const sharingContext = useSharingContext()\n  const { isOwner, byDocId, hasWriteAccess, refresh, allLoaded } =\n    sharingContext\n  const { displayedFolder } = useDisplayedFolder()\n  const { pushModal, popModal } = useModalContext()\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n  const dispatch = useDispatch()\n  const vaultClient = useVaultClient()\n  const isInRootOfSharedDrive = displayedFolder?.dir_id === SHARED_DRIVES_DIR_ID\n  const { isFabDisplayed, setIsFabDisplayed } = useContext(FabContext)\n  const { isSelectionBarVisible } = useSelectionContext()\n\n  const { sharedDriveResult, fetchStatus, hasMore, fetchMore } =\n    useSharedDriveFolder({\n      driveId,\n      folderId\n    })\n\n  const queryResults =\n    sharedDriveResult.included !== undefined\n      ? [{ fetchStatus, data: sharedDriveResult.included, hasMore, fetchMore }]\n      : []\n\n  const canWriteToCurrentFolder = hasWriteAccess(folderId, driveId)\n\n  const { hasClipboardData } = useClipboardContext()\n\n  useEffect(() => {\n    setIsFabDisplayed(canWriteToCurrentFolder && isMobile)\n    return () => {\n      setIsFabDisplayed(false)\n    }\n  }, [setIsFabDisplayed, isMobile, canWriteToCurrentFolder])\n\n  const [sortOrder, setSortOrder, isSettingsLoaded] = useFolderSort(folderId)\n\n  useKeyboardShortcuts({\n    canPaste: hasClipboardData && canWriteToCurrentFolder,\n    client,\n    items: sharedDriveResult?.included || [],\n    sharingContext,\n    allowCut: canWriteToCurrentFolder,\n    allowCopy: false,\n    allowDelete: canWriteToCurrentFolder,\n    pushModal,\n    popModal,\n    refresh\n  })\n  const actionsOptions = useMemo(\n    () => ({\n      client,\n      t,\n      vaultClient,\n      pathname,\n      isOwner,\n      isMobile,\n      driveId,\n      hasWriteAccess: canWriteToCurrentFolder,\n      byDocId,\n      dispatch,\n      canMove: canWriteToCurrentFolder,\n      canDuplicate: canWriteToCurrentFolder,\n      navigate,\n      showAlert,\n      pushModal,\n      popModal,\n      refresh,\n      allLoaded\n    }),\n    [\n      client,\n      t,\n      vaultClient,\n      pathname,\n      isOwner,\n      isMobile,\n      driveId,\n      canWriteToCurrentFolder,\n      byDocId,\n      dispatch,\n      navigate,\n      showAlert,\n      pushModal,\n      popModal,\n      refresh,\n      allLoaded\n    ]\n  )\n\n  const actions = useMemo(\n    () =>\n      makeActions(\n        [\n          share,\n          download,\n          hr,\n          rename,\n          moveTo,\n          duplicateTo,\n          personalizeFolder,\n          infos,\n          hr,\n          versions,\n          hr,\n          trash\n        ],\n        actionsOptions\n      ),\n    [actionsOptions]\n  )\n\n  const DropzoneComp =\n    flag('drive.virtualization.enabled') && !isMobile ? DropzoneDnD : Dropzone\n\n  return (\n    <FolderView>\n      <DropzoneComp\n        disabled={!canWriteToCurrentFolder}\n        displayedFolder={displayedFolder}\n      >\n        <FolderViewHeader>\n          <SharedDriveBreadcrumb driveId={driveId} folderId={folderId} />\n          <Toolbar\n            canUpload={false}\n            canCreateFolder={canWriteToCurrentFolder}\n            driveId={driveId}\n            showShareButton={isInRootOfSharedDrive}\n          />\n        </FolderViewHeader>\n\n        {flag('drive.virtualization.enabled') && !isMobile ? (\n          <FolderViewBodyVz\n            actions={actions}\n            queryResults={queryResults}\n            currentFolderId={folderId}\n            displayedFolder={displayedFolder}\n            canDrag\n            canUpload={canWriteToCurrentFolder}\n            withFilePath={false}\n            driveId={driveId}\n            orderProps={{\n              sortOrder,\n              setOrder: setSortOrder,\n              isSettingsLoaded\n            }}\n          />\n        ) : (\n          <SharedDriveFolderBody\n            folderId={folderId}\n            queryResults={queryResults}\n          />\n        )}\n        <Outlet />\n        {isFabDisplayed && (\n          <AddMenuProvider\n            componentsProps={{\n              AddMenu: {\n                anchorOrigin: {\n                  vertical: 'top',\n                  horizontal: 'left'\n                }\n              }\n            }}\n            canCreateFolder={true}\n            canUpload={true}\n            disabled={false}\n            refreshFolderContent={refresh}\n            displayedFolder={displayedFolder}\n            isSelectionBarVisible={isSelectionBarVisible}\n          >\n            <FabWithAddMenuContext />\n          </AddMenuProvider>\n        )}\n      </DropzoneComp>\n    </FolderView>\n  )\n}\n\nexport { SharedDriveFolderView }\n"
  },
  {
    "path": "src/modules/views/Sharings/FilesViewerSharings.jsx",
    "content": "import React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useQuery } from 'cozy-client'\n\nimport withSharedDocumentIds from './withSharedDocumentIds'\n\nimport { FilesViewerLoading } from '@/components/FilesViewerLoading'\nimport { useCurrentFolderId } from '@/hooks'\nimport FilesViewer from '@/modules/viewer/FilesViewer'\nimport { buildSharingsQuery } from '@/queries'\n\nconst FilesViewerSharing = ({ sharedDocumentIds }) => {\n  const currentFolderId = useCurrentFolderId()\n  const filesQuery = buildSharingsQuery({ ids: sharedDocumentIds })\n  const results = useQuery(filesQuery.definition, filesQuery.options)\n  const navigate = useNavigate()\n\n  if (results.data) {\n    const viewableFiles = results.data.filter(f => f.type !== 'directory')\n    const basePath = currentFolderId\n      ? `/sharings/${currentFolderId}`\n      : '/sharings'\n    return (\n      <FilesViewer\n        files={viewableFiles}\n        filesQuery={results}\n        onClose={() => navigate(basePath)}\n        onChange={fileId => navigate(`${basePath}/file/${fileId}`)}\n      />\n    )\n  } else {\n    return <FilesViewerLoading />\n  }\n}\n\nexport default withSharedDocumentIds(FilesViewerSharing)\n"
  },
  {
    "path": "src/modules/views/Sharings/SharingsFolderView.jsx",
    "content": "import React, { useMemo, useContext, useEffect } from 'react'\nimport { useDispatch } from 'react-redux'\nimport { useNavigate, Outlet, useLocation } from 'react-router-dom'\n\nimport { useQuery, useClient } from 'cozy-client'\nimport flag from 'cozy-flags'\nimport { useSharingContext } from 'cozy-sharing'\nimport SharedDocuments from 'cozy-sharing/dist/components/SharedDocuments'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport FolderView from '../Folder/FolderView'\nimport FolderViewBody from '../Folder/FolderViewBody'\nimport FolderViewBreadcrumb from '../Folder/FolderViewBreadcrumb'\nimport FolderViewHeader from '../Folder/FolderViewHeader'\n\nimport useHead from '@/components/useHead'\nimport { useCurrentFolderId, useDisplayedFolder, useFolderSort } from '@/hooks'\nimport { FabContext } from '@/lib/FabProvider'\nimport { useModalContext } from '@/lib/ModalContext'\nimport {\n  share,\n  download,\n  trash,\n  rename,\n  qualify,\n  versions,\n  selectAllItems\n} from '@/modules/actions'\nimport { moveTo } from '@/modules/actions/components/moveTo'\nimport { personalizeFolder } from '@/modules/actions/components/personalizeFolder'\nimport { makeExtraColumnsNamesFromMedia } from '@/modules/certifications'\nimport { useExtraColumns } from '@/modules/certifications/useExtraColumns'\nimport AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'\nimport FabWithAddMenuContext from '@/modules/drive/FabWithAddMenuContext'\nimport Toolbar from '@/modules/drive/Toolbar'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport Dropzone from '@/modules/upload/Dropzone'\nimport FolderViewBodyVz from '@/modules/views/Folder/virtualized/FolderViewBody'\nimport {\n  buildDriveQuery,\n  buildFileWithSpecificMetadataAttributeQuery\n} from '@/queries'\n\nconst desktopExtraColumnsNames = ['carbonCopy', 'electronicSafe']\nconst mobileExtraColumnsNames = []\n\nconst SharingsFolderView = ({ sharedDocumentIds }) => {\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n  const currentFolderId = useCurrentFolderId()\n  const { isMobile } = useBreakpoints()\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n  const client = useClient()\n  const { allLoaded, hasWriteAccess, refresh, isOwner, byDocId } =\n    useSharingContext()\n  const { pushModal, popModal } = useModalContext()\n  const dispatch = useDispatch()\n  const { displayedFolder, isNotFound } = useDisplayedFolder()\n  const { toggleSelectAllItems, isSelectAll, isSelectionBarVisible } =\n    useSelectionContext()\n  const { isFabDisplayed, setIsFabDisplayed } = useContext(FabContext)\n  useHead()\n\n  const extraColumnsNames = makeExtraColumnsNamesFromMedia({\n    isMobile,\n    desktopExtraColumnsNames,\n    mobileExtraColumnsNames\n  })\n\n  const extraColumns = useExtraColumns({\n    columnsNames: extraColumnsNames,\n    queryBuilder: buildFileWithSpecificMetadataAttributeQuery,\n    currentFolderId\n  })\n\n  const [sortOrder, setSortOrder, isSettingsLoaded] =\n    useFolderSort(currentFolderId)\n\n  const folderQuery = buildDriveQuery({\n    currentFolderId,\n    type: 'directory',\n    sortAttribute: sortOrder.attribute,\n    sortOrder: sortOrder.order\n  })\n  const fileQuery = buildDriveQuery({\n    currentFolderId,\n    type: 'file',\n    sortAttribute: sortOrder.attribute,\n    sortOrder: sortOrder.order\n  })\n  const foldersResult = useQuery(folderQuery.definition, folderQuery.options)\n  const filesResult = useQuery(fileQuery.definition, fileQuery.options)\n\n  const allResults = [foldersResult, filesResult]\n\n  const hasWrite = hasWriteAccess(currentFolderId)\n\n  useEffect(() => {\n    setIsFabDisplayed(hasWrite && isMobile)\n    return () => {\n      setIsFabDisplayed(false)\n    }\n  }, [setIsFabDisplayed, isMobile, hasWrite])\n\n  const actionsOptions = {\n    client,\n    t,\n    pushModal,\n    popModal,\n    refresh,\n    dispatch,\n    navigate,\n    showAlert,\n    pathname,\n    hasWriteAccess: hasWrite,\n    canMove: true,\n    allLoaded,\n    isOwner,\n    byDocId,\n    selectAll: () =>\n      toggleSelectAllItems(\n        [foldersResult, filesResult].map(query => query.data).flat()\n      ),\n    isSelectAll\n  }\n  const actions = makeActions(\n    [\n      selectAllItems,\n      share,\n      download,\n      trash,\n      rename,\n      moveTo,\n      qualify,\n      versions,\n      personalizeFolder\n    ],\n    actionsOptions\n  )\n\n  const rootBreadcrumbPath = useMemo(\n    () => ({\n      name: t('breadcrumb.title_sharings')\n    }),\n    [t]\n  )\n\n  return (\n    <FolderView isNotFound={isNotFound}>\n      <Dropzone disabled={!hasWrite} displayedFolder={displayedFolder}>\n        <FolderViewHeader>\n          {currentFolderId && (\n            <FolderViewBreadcrumb\n              sharedDocumentIds={sharedDocumentIds}\n              rootBreadcrumbPath={rootBreadcrumbPath}\n              currentFolderId={currentFolderId}\n            />\n          )}\n          <Toolbar canUpload={hasWrite} canCreateFolder={hasWrite} />\n        </FolderViewHeader>\n        {flag('drive.virtualization.enabled') && !isMobile ? (\n          <FolderViewBodyVz\n            actions={actions}\n            queryResults={allResults}\n            currentFolderId={currentFolderId}\n            displayedFolder={displayedFolder}\n            canDrag\n            canUpload={hasWrite}\n            orderProps={{\n              sortOrder,\n              setOrder: setSortOrder,\n              isSettingsLoaded\n            }}\n          />\n        ) : (\n          <FolderViewBody\n            actions={actions}\n            queryResults={allResults}\n            canSort\n            extraColumns={extraColumns}\n            currentFolderId={currentFolderId}\n          />\n        )}\n        <Outlet />\n        {isFabDisplayed && (\n          <AddMenuProvider\n            componentsProps={{\n              AddMenu: {\n                anchorOrigin: {\n                  vertical: 'top',\n                  horizontal: 'left'\n                }\n              }\n            }}\n            canCreateFolder={hasWrite}\n            canUpload={hasWrite}\n            disabled={!hasWrite}\n            refreshFolderContent={refresh}\n            displayedFolder={displayedFolder}\n            isSelectionBarVisible={isSelectionBarVisible}\n          >\n            <FabWithAddMenuContext />\n          </AddMenuProvider>\n        )}\n      </Dropzone>\n    </FolderView>\n  )\n}\n\nconst FolderViewWithSharings = props => (\n  <SharedDocuments>\n    {({ sharedDocuments }) => (\n      <SharingsFolderView {...props} sharedDocumentIds={sharedDocuments} />\n    )}\n  </SharedDocuments>\n)\n\nexport default FolderViewWithSharings\n"
  },
  {
    "path": "src/modules/views/Sharings/index.jsx",
    "content": "import React, { useMemo } from 'react'\nimport { useDispatch } from 'react-redux'\nimport { useNavigate, useLocation, Outlet } from 'react-router-dom'\n\nimport { useClient, hasQueryBeenLoaded, useQuery } from 'cozy-client'\nimport flag from 'cozy-flags'\nimport {\n  useSharingContext,\n  useNativeFileSharing,\n  shareNative\n} from 'cozy-sharing'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { Content } from 'cozy-ui/transpiled/react/Layout'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport withSharedDocumentIds from './withSharedDocumentIds'\nimport FolderView from '../Folder/FolderView'\nimport FolderViewBody from '../Folder/FolderViewBody'\nimport FolderViewHeader from '../Folder/FolderViewHeader'\nimport FolderViewBodyVz from '../Folder/virtualized/FolderViewBody'\n\nimport useHead from '@/components/useHead'\nimport {\n  SHARED_DRIVES_DIR_ID,\n  SHARING_TAB_ALL,\n  SHARING_TAB_DRIVES\n} from '@/constants/config'\nimport { useFolderSort } from '@/hooks'\nimport { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'\nimport { useTransformFolderListHasSharedDriveShortcuts } from '@/hooks/useTransformFolderListHasSharedDriveShortcuts'\nimport { useModalContext } from '@/lib/ModalContext'\nimport {\n  download,\n  rename,\n  infos,\n  versions,\n  share,\n  hr,\n  selectAllItems,\n  summariseByAI\n} from '@/modules/actions'\nimport { addToFavorites } from '@/modules/actions/components/addToFavorites'\nimport { moveTo } from '@/modules/actions/components/moveTo'\nimport { removeFromFavorites } from '@/modules/actions/components/removeFromFavorites'\nimport { MobileAwareBreadcrumb as Breadcrumb } from '@/modules/breadcrumb/components/MobileAwareBreadcrumb'\nimport { makeExtraColumnsNamesFromMedia } from '@/modules/certifications'\nimport { useExtraColumns } from '@/modules/certifications/useExtraColumns'\nimport AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'\nimport FabWithAddMenuContext from '@/modules/drive/FabWithAddMenuContext'\nimport Toolbar from '@/modules/drive/Toolbar'\nimport FileListRowsPlaceholder from '@/modules/filelist/FileListRowsPlaceholder'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport { leaveSharedDrive } from '@/modules/shareddrives/components/actions/leaveSharedDrive'\nimport { shareSharedDrive } from '@/modules/shareddrives/components/actions/shareSharedDrive'\nimport {\n  buildSharingsQuery,\n  buildSharingsWithMetadataAttributeQuery\n} from '@/queries'\nconst desktopExtraColumnsNames = ['carbonCopy', 'electronicSafe']\nconst mobileExtraColumnsNames = []\n\nexport const SharingsView = ({ sharedDocumentIds = [] }) => {\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n  const tab = SHARING_TAB_ALL\n  const { t, lang } = useI18n()\n  const { isMobile } = useBreakpoints()\n  const client = useClient()\n  const { pushModal, popModal } = useModalContext()\n  const { isSelectionBarVisible, toggleSelectAllItems, isSelectAll } =\n    useSelectionContext()\n  const sharingContext = useSharingContext()\n  const { allLoaded, refresh } = sharingContext\n  const { isNativeFileSharingAvailable, shareFilesNative } =\n    useNativeFileSharing()\n  const dispatch = useDispatch()\n  useHead({ title: t('breadcrumb.title_sharings') })\n  const { showAlert } = useAlert()\n  const [sortOrder, setSortOrder, isSettingsLoaded] = useFolderSort('sharings')\n\n  const isEnabledSharedDrive = flag('drive.shared-drive.enabled')\n  const isEnabledFederatedSharedFolder = flag(\n    'drive.federated-shared-folder.enabled'\n  )\n\n  const extraColumnsNames = makeExtraColumnsNamesFromMedia({\n    isMobile,\n    desktopExtraColumnsNames,\n    mobileExtraColumnsNames\n  })\n\n  const extraColumns = useExtraColumns({\n    columnsNames: extraColumnsNames,\n    queryBuilder: buildSharingsWithMetadataAttributeQuery,\n    sharedDocumentIds\n  })\n\n  const query = useMemo(\n    () =>\n      buildSharingsQuery({\n        ids: sharedDocumentIds,\n        enabled: allLoaded && sharedDocumentIds?.length > 0\n      }),\n    [sharedDocumentIds, allLoaded]\n  )\n  const result = useQuery(query.definition, query.options)\n\n  /**\n   * Problem:\n   * - In the recipient's Sharing section, shared drives appear only as shortcuts\n   *   and don’t contain a root folder id (the folder id in the owner's shared drive).\n   *\n   * Why:\n   * - To open a shared drive, we need a URL like `shareddrive/:driveId/:rootFolderId`.\n   * - This information exists in `io.cozy.sharings`, which includes root folder id,\n   *   but the structure is not compatible with the directory format expected\n   *   in the Sharing UI.\n   *\n   * Solution:\n   * - Transform `sharedDrives` into directory-like objects with the required\n   *   properties (`id`, `path`, `attributes`,...) so they can be displayed\n   *   and opened consistently.\n   */\n  const {\n    sharedDrives: transformedSharedDrives,\n    nonSharedDriveList,\n    sharedDrivesLoaded\n  } = useTransformFolderListHasSharedDriveShortcuts(result.data)\n\n  const filteredResult = useMemo(() => {\n    if (!isEnabledSharedDrive && !isEnabledFederatedSharedFolder) {\n      const filteredResultData =\n        result.data?.filter(item => !(item.dir_id === SHARED_DRIVES_DIR_ID)) ||\n        []\n      return {\n        ...result,\n        // If there are no shared documents, we consider the data is loaded by setting fetchStatus to 'loaded' and lastFetch to now.\n        fetchStatus:\n          sharedDocumentIds?.length > 0 ? result.fetchStatus : 'loaded',\n        lastFetch:\n          // eslint-disable-next-line react-hooks/purity\n          sharedDocumentIds?.length > 0 ? result.lastFetch : Date.now(),\n        data: filteredResultData,\n        count: filteredResultData.length\n      }\n    }\n    const combinedData =\n      tab === SHARING_TAB_DRIVES\n        ? transformedSharedDrives\n        : [...transformedSharedDrives, ...nonSharedDriveList]\n\n    return {\n      ...result,\n      fetchStatus:\n        sharedDocumentIds?.length > 0 ? result.fetchStatus : 'loaded',\n      // eslint-disable-next-line react-hooks/purity\n      lastFetch: sharedDocumentIds?.length > 0 ? result.lastFetch : Date.now(),\n      data: combinedData,\n      count: combinedData.length\n    }\n  }, [\n    isEnabledSharedDrive,\n    isEnabledFederatedSharedFolder,\n    tab,\n    transformedSharedDrives,\n    nonSharedDriveList,\n    result,\n    sharedDocumentIds?.length\n  ])\n\n  useKeyboardShortcuts({\n    onPaste: () => refresh(),\n    client,\n    items: filteredResult?.data || [],\n    sharingContext,\n    allowCopy: false,\n    pushModal,\n    popModal,\n    refresh\n  })\n\n  const actionsOptions = {\n    client,\n    t,\n    lang,\n    pushModal,\n    popModal,\n    refresh,\n    dispatch,\n    navigate,\n    pathname,\n    hasWriteAccess: true,\n    canMove: true,\n    isPublic: false,\n    shouldHideIfSharedDriveRecipient: true,\n    allLoaded,\n    showAlert,\n    isMobile,\n    isNativeFileSharingAvailable,\n    shareFilesNative,\n    selectAll: () => toggleSelectAllItems(result.data),\n    isSelectAll\n  }\n\n  const actions = makeActions(\n    [\n      selectAllItems,\n      share,\n      shareNative,\n      shareSharedDrive,\n      download,\n      hr,\n      summariseByAI,\n      hr,\n      rename,\n      moveTo,\n      addToFavorites,\n      removeFromFavorites,\n      leaveSharedDrive,\n      infos,\n      hr,\n      versions\n    ],\n    actionsOptions\n  )\n\n  return (\n    <FolderView>\n      <Content className={isMobile ? '' : 'u-pt-1'}>\n        <FolderViewHeader>\n          <Breadcrumb path={[{ name: t('breadcrumb.title_sharings') }]} />\n          <Toolbar canUpload={false} canCreateFolder={false} />\n        </FolderViewHeader>\n        {!allLoaded ||\n        !sharedDrivesLoaded ||\n        !hasQueryBeenLoaded(filteredResult) ? (\n          <FileListRowsPlaceholder />\n        ) : (\n          <>\n            {flag('drive.virtualization.enabled') && !isMobile ? (\n              <FolderViewBodyVz\n                actions={actions}\n                queryResults={[filteredResult]}\n                withFilePath={true}\n                orderProps={{\n                  sortOrder,\n                  setOrder: setSortOrder,\n                  isSettingsLoaded\n                }}\n              />\n            ) : (\n              <FolderViewBody\n                actions={actions}\n                queryResults={[filteredResult]}\n                canSort={true}\n                withFilePath={true}\n                extraColumns={extraColumns}\n                orderProps={{\n                  sortOrder,\n                  setOrder: setSortOrder,\n                  isSettingsLoaded\n                }}\n              />\n            )}\n            <Outlet />\n          </>\n        )}\n        {isMobile && (\n          <AddMenuProvider\n            canCreateFolder={true}\n            canUpload={true}\n            disabled={false}\n            displayedFolder={null}\n            isSelectionBarVisible={isSelectionBarVisible}\n            isPublic={false}\n            refreshFolderContent={() => {}}\n          >\n            <FabWithAddMenuContext noSidebar={false} />\n          </AddMenuProvider>\n        )}\n      </Content>\n    </FolderView>\n  )\n}\n\nexport default withSharedDocumentIds(SharingsView)\n"
  },
  {
    "path": "src/modules/views/Sharings/index.spec.jsx",
    "content": "import { render, fireEvent, waitFor } from '@testing-library/react'\nimport React from 'react'\n\nimport { useQuery } from 'cozy-client'\n\nimport { SharingsView } from './index'\nimport {\n  generateFileFixtures,\n  getByTextWithMarkup,\n  removeNonASCII\n} from '../testUtils'\nimport AppLike from 'test/components/AppLike'\nimport { setupStoreAndClient } from 'test/setup'\n\nconst mockNavigate = jest.fn()\nconst mockSharingContext = jest.fn()\n\njest.mock('react-router-dom', () => ({\n  ...jest.requireActual('react-router-dom'),\n  useNavigate: () => mockNavigate\n}))\njest.mock('cozy-sharing', () => ({\n  __esModule: true,\n  ...jest.requireActual('cozy-sharing'),\n  useSharingContext: () => mockSharingContext()\n}))\njest.mock('components/pushClient', () => ({\n  isMacOS: jest.fn(() => false),\n  isIOS: jest.fn(() => false),\n  isLinux: jest.fn(() => false),\n  isAndroid: jest.fn(() => false)\n}))\njest.mock('components/pushClient/Banner', () => () => <div>Banner</div>)\njest.mock('cozy-client/dist/hooks/useQuery', () =>\n  jest.fn(() => ({\n    fetchStatus: '',\n    data: []\n  }))\n)\njest.mock('cozy-keys-lib', () => ({\n  useVaultClient: jest.fn()\n}))\njest.mock('cozy-client/dist/utils', () => ({\n  ...jest.requireActual('cozy-client/dist/utils'),\n  hasQueryBeenLoaded: jest.fn().mockReturnValue(true)\n}))\njest.mock('components/useHead', () => jest.fn())\n\nconst setup = () => {\n  const { store, client } = setupStoreAndClient()\n\n  client.plugins.realtime = {\n    subscribe: jest.fn(),\n    unsubscribe: jest.fn()\n  }\n  client.query = jest.fn().mockReturnValue({ data: [] })\n  client.stackClient.fetchJSON = jest\n    .fn()\n    .mockReturnValue({ data: [], rows: [] })\n\n  const rendered = render(\n    <AppLike client={client} store={store}>\n      <SharingsView />\n    </AppLike>\n  )\n  return { ...rendered, client }\n}\n\ndescribe('Sharings View', () => {\n  const nbFiles = 2\n  const path = '/test'\n  const dir_id = 'dirIdParent'\n  const updated_at = '2020-05-14T10:33:31.365224+02:00'\n\n  const filesFixture = generateFileFixtures({\n    nbFiles,\n    path,\n    dir_id,\n    updated_at\n  })\n\n  const filesFixtureWithPath = {\n    data: filesFixture.map(f => {\n      return {\n        ...f,\n        displayedPath: path\n      }\n    })\n  }\n\n  beforeAll(() => {\n    // TODO : Remove nested <a> on File when withFilePath is true\n    jest.spyOn(console, 'error').mockImplementation()\n  })\n\n  afterAll(() => {\n    jest.clearAllMocks()\n  })\n\n  it('should display placeholder when all files are not loaded', async () => {\n    mockSharingContext.mockReturnValue({ byDocId: [], allLoaded: false })\n    const { container } = setup()\n\n    await waitFor(() => {\n      expect(\n        container.querySelector('.fil-content-file-placeholder')\n      ).not.toBeNull()\n    })\n  })\n\n  it('should not display placeholder when all files are loaded', async () => {\n    mockSharingContext.mockReturnValue({ byDocId: [], allLoaded: true })\n    useQuery.mockReturnValue(filesFixtureWithPath)\n\n    const { container } = setup()\n\n    await waitFor(() => {\n      expect(\n        container.querySelector('.fil-content-file-placeholder')\n      ).toBeNull()\n    })\n  })\n\n  it('tests the sharings view', async () => {\n    // TODO : Fix https://github.com/cozy/cozy-drive/issues/2913\n    jest.spyOn(console, 'warn').mockImplementation()\n\n    useQuery.mockReturnValue(filesFixtureWithPath)\n\n    const { getByText } = setup()\n\n    await waitFor(() => {\n      // Get the HTMLElement containing the filename if exist. If not throw\n      const el0 = getByText(`foobar0`)\n      // Check if the filename is displayed with the extension. If not throw\n      getByTextWithMarkup(getByText, `foobar0.pdf`)\n      // get the FileRow element\n      const fileRow0 = el0.closest('.fil-content-row')\n      // check if the date is right\n      expect(fileRow0.getElementsByTagName('time')[0].dateTime).toEqual(\n        updated_at\n      )\n      // check the path to the parent's folder\n      const linkElement0 = fileRow0.getElementsByClassName('fil-file-path')[0]\n      expect(removeNonASCII(linkElement0.textContent)).toEqual(path)\n\n      expect(linkElement0.href.endsWith(`#/folder/${dir_id}`)).toBe(true)\n\n      // check if the ActionMenu is displayed\n      fireEvent.click(fileRow0.getElementsByTagName('button')[0])\n      const el1 = getByText(`foobar1`)\n      const parentDiv1 = el1.closest('.fil-file')\n      expect(\n        removeNonASCII(\n          parentDiv1.getElementsByClassName('fil-file-path')[0].textContent\n        )\n      ).toEqual(path)\n\n      // navigates  to the history view\n      const historyItem = getByText('History')\n      fireEvent.click(historyItem)\n    })\n\n    expect(mockNavigate).toHaveBeenCalledWith('/file/file-foobar0/revision')\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Sharings/withSharedDocumentIds.jsx",
    "content": "import React from 'react'\n\nimport SharedDocuments from 'cozy-sharing/dist/components/SharedDocuments'\n\nconst withSharedDocumentIds = BaseComponent => {\n  const WrapperComponent = props => (\n    <SharedDocuments>\n      {({ sharedDocuments, allLoaded }) => (\n        <BaseComponent\n          {...props}\n          sharedDocumentIds={sharedDocuments}\n          allLoaded={allLoaded}\n        />\n      )}\n    </SharedDocuments>\n  )\n\n  WrapperComponent.displayName = `withSharedDocumentIds(${BaseComponent.displayName})`\n\n  return WrapperComponent\n}\n\nexport default withSharedDocumentIds\n"
  },
  {
    "path": "src/modules/views/Trash/FilesViewerTrash.jsx",
    "content": "import React from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useQuery } from 'cozy-client'\n\nimport { FilesViewerLoading } from '@/components/FilesViewerLoading'\nimport useHead from '@/components/useHead'\nimport { useCurrentFolderId, useFolderSort } from '@/hooks'\nimport FilesViewer from '@/modules/viewer/FilesViewer'\nimport { buildTrashQuery } from '@/queries'\n\nconst FilesViewerTrash = () => {\n  const currentFolderId = useCurrentFolderId()\n  const [sortOrder] = useFolderSort(currentFolderId)\n  const navigate = useNavigate()\n  useHead()\n\n  const fileQuery = buildTrashQuery({\n    currentFolderId,\n    type: 'file',\n    sortAttribute: sortOrder.attribute,\n    sortOrder: sortOrder.order\n  })\n\n  const filesResult = useQuery(fileQuery.definition, fileQuery.options)\n  if (filesResult.data) {\n    const viewableFiles = filesResult.data\n    return (\n      <FilesViewer\n        files={viewableFiles}\n        filesQuery={filesResult}\n        onClose={() => navigate(`/trash/${currentFolderId}`)}\n        onChange={fileId =>\n          navigate(`/trash/${currentFolderId}/file/${fileId}`)\n        }\n      />\n    )\n  } else {\n    return <FilesViewerLoading />\n  }\n}\n\nexport default FilesViewerTrash\n"
  },
  {
    "path": "src/modules/views/Trash/TrashDestroyView.tsx",
    "content": "import React, { FC } from 'react'\nimport { useNavigate, useLocation, Navigate } from 'react-router-dom'\n\nimport { useQuery, hasQueryBeenLoaded, useClient } from 'cozy-client'\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport { useSharingContext } from 'cozy-sharing'\n\nimport { LoaderModal } from '@/components/LoaderModal'\nimport DestroyConfirm from '@/modules/trash/components/DestroyConfirm'\nimport { buildParentsByIdsQuery } from '@/queries'\n\nconst TrashDestroyView: FC = () => {\n  const { refresh } = useSharingContext()\n  const navigate = useNavigate()\n  const client = useClient()\n  const { state } = useLocation() as {\n    state: { fileIds?: string[] }\n  }\n\n  const handleClose = (): void => {\n    navigate('..', { replace: true })\n  }\n\n  const hasFileIds = state.fileIds !== undefined\n\n  const fileQuery = buildParentsByIdsQuery(state.fileIds ?? [])\n  const fileResult = useQuery(fileQuery.definition, {\n    ...fileQuery.options,\n    enabled: hasFileIds\n  }) as {\n    data?: IOCozyFile[] | null\n  }\n\n  if (!hasFileIds) {\n    return <Navigate to=\"..\" replace={true} />\n  }\n\n  const handleConfirm = async (): Promise<void> => {\n    if (!fileResult.data) return\n    for (const file of fileResult.data) {\n      // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n      await client?.collection('io.cozy.files').deleteFilePermanently(file.id)\n    }\n    refresh()\n  }\n\n  const hasData = Array.isArray(fileResult.data)\n    ? fileResult.data.length > 0\n    : fileResult.data\n\n  if (hasQueryBeenLoaded(fileResult) && hasData) {\n    return (\n      <DestroyConfirm\n        files={fileResult.data}\n        onConfirm={handleConfirm}\n        onClose={handleClose}\n      />\n    )\n  }\n\n  return <LoaderModal />\n}\n\nexport { TrashDestroyView }\n"
  },
  {
    "path": "src/modules/views/Trash/TrashEmptyView.tsx",
    "content": "import React, { FC } from 'react'\nimport { useNavigate } from 'react-router-dom'\n\nimport { useClient } from 'cozy-client'\n\nimport { EmptyTrashConfirm } from '@/modules/trash/components/EmptyTrashConfirm'\n\nconst TrashEmptyView: FC = () => {\n  const navigate = useNavigate()\n  const client = useClient()\n\n  const handleClose = (): void => {\n    navigate('..', { replace: true })\n  }\n\n  const onConfirm = async (): Promise<void> => {\n    // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call\n    await client?.collection('io.cozy.files').emptyTrash()\n  }\n\n  return <EmptyTrashConfirm onConfirm={onConfirm} onClose={handleClose} />\n}\n\nexport { TrashEmptyView }\n"
  },
  {
    "path": "src/modules/views/Trash/TrashFolderView.jsx",
    "content": "import React from 'react'\nimport { useNavigate, Outlet, useLocation } from 'react-router-dom'\n\nimport { useQuery, useClient } from 'cozy-client'\nimport flag from 'cozy-flags'\nimport { useSharingContext } from 'cozy-sharing'\nimport { makeActions } from 'cozy-ui/transpiled/react/ActionsMenu/Actions'\nimport { Content } from 'cozy-ui/transpiled/react/Layout'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport { useI18n } from 'twake-i18n'\n\nimport FolderView from '../Folder/FolderView'\nimport FolderViewBody from '../Folder/FolderViewBody'\nimport FolderViewHeader from '../Folder/FolderViewHeader'\nimport FolderViewBodyVz from '../Folder/virtualized/FolderViewBody'\n\nimport useHead from '@/components/useHead'\nimport { SORT_BY_UPDATE_DATE } from '@/config/sort'\nimport { useCurrentFolderId, useDisplayedFolder, useFolderSort } from '@/hooks'\nimport { restore, selectAllItems } from '@/modules/actions'\nimport { makeExtraColumnsNamesFromMedia } from '@/modules/certifications'\nimport { useExtraColumns } from '@/modules/certifications/useExtraColumns'\nimport AddMenuProvider from '@/modules/drive/AddMenu/AddMenuProvider'\nimport FabWithAddMenuContext from '@/modules/drive/FabWithAddMenuContext'\nimport { useSelectionContext } from '@/modules/selection/SelectionProvider'\nimport { TrashBreadcrumb } from '@/modules/trash/components/TrashBreadcrumb'\nimport { TrashToolbar } from '@/modules/trash/components/TrashToolbar'\nimport { destroy } from '@/modules/trash/components/actions/destroy'\nimport {\n  buildTrashQuery,\n  buildFileWithSpecificMetadataAttributeQuery\n} from '@/queries'\n\nconst desktopExtraColumnsNames = ['carbonCopy', 'electronicSafe']\nconst mobileExtraColumnsNames = []\n\nexport const TrashFolderView = () => {\n  const { isMobile } = useBreakpoints()\n  const navigate = useNavigate()\n  const { pathname } = useLocation()\n  const { isSelectionBarVisible, toggleSelectAllItems, isSelectAll } =\n    useSelectionContext()\n  const currentFolderId = useCurrentFolderId()\n  const { t } = useI18n()\n  const { refresh } = useSharingContext()\n  const client = useClient()\n  useHead()\n\n  const { displayedFolder, isNotFound } = useDisplayedFolder()\n\n  const extraColumnsNames = makeExtraColumnsNamesFromMedia({\n    isMobile,\n    desktopExtraColumnsNames,\n    mobileExtraColumnsNames\n  })\n\n  const extraColumns = useExtraColumns({\n    columnsNames: extraColumnsNames,\n    queryBuilder: buildFileWithSpecificMetadataAttributeQuery,\n    currentFolderId\n  })\n\n  const [sortOrder, setSortOrder, isSettingsLoaded] =\n    useFolderSort(currentFolderId)\n\n  // Sort by size does not work for directory, so in case sorting by size we will change to default sorting\n  const folderQuery = buildTrashQuery({\n    currentFolderId,\n    type: 'directory',\n    sortAttribute:\n      sortOrder.attribute !== 'size'\n        ? sortOrder.attribute\n        : SORT_BY_UPDATE_DATE.attribute,\n    sortOrder:\n      sortOrder.attribute !== 'size'\n        ? sortOrder.order\n        : SORT_BY_UPDATE_DATE.order\n  })\n  const fileQuery = buildTrashQuery({\n    currentFolderId,\n    type: 'file',\n    sortAttribute: sortOrder.attribute,\n    sortOrder: sortOrder.order,\n    limit: 50\n  })\n\n  const foldersResult = useQuery(folderQuery.definition, folderQuery.options)\n  const filesResult = useQuery(fileQuery.definition, fileQuery.options)\n\n  const actions = makeActions([selectAllItems, restore, destroy], {\n    client,\n    t,\n    refresh,\n    navigate,\n    pathname,\n    selectAll: () =>\n      toggleSelectAllItems(\n        [foldersResult, filesResult].map(query => query.data).flat()\n      ),\n    isSelectAll\n  })\n\n  return (\n    <FolderView isNotFound={isNotFound}>\n      <Content className={isMobile ? '' : 'u-pt-1'}>\n        <FolderViewHeader>\n          <TrashBreadcrumb currentFolderId={currentFolderId} />\n          <TrashToolbar />\n        </FolderViewHeader>\n        {flag('drive.virtualization.enabled') && !isMobile ? (\n          <FolderViewBodyVz\n            actions={actions}\n            queryResults={[foldersResult, filesResult]}\n            currentFolderId={currentFolderId}\n            withFilePath={false}\n            canUpload={false}\n            orderProps={{\n              sortOrder,\n              setOrder: setSortOrder,\n              isSettingsLoaded\n            }}\n          />\n        ) : (\n          <FolderViewBody\n            currentFolderId={currentFolderId}\n            displayedFolder={displayedFolder}\n            actions={actions}\n            queryResults={[foldersResult, filesResult]}\n            withFilePath={false}\n            canSort\n            extraColumns={extraColumns}\n            canUpload={false}\n            orderProps={{\n              sortOrder,\n              setOrder: setSortOrder,\n              isSettingsLoaded\n            }}\n          />\n        )}\n        <Outlet />\n        {isMobile && (\n          <AddMenuProvider\n            canCreateFolder={true}\n            canUpload={true}\n            disabled={false}\n            displayedFolder={displayedFolder}\n            isSelectionBarVisible={isSelectionBarVisible}\n            isPublic={false}\n            refreshFolderContent={() => {}}\n          >\n            <FabWithAddMenuContext noSidebar={false} />\n          </AddMenuProvider>\n        )}\n      </Content>\n    </FolderView>\n  )\n}\n\nexport default TrashFolderView\n"
  },
  {
    "path": "src/modules/views/Trash/TrashFolderView.spec.jsx",
    "content": "import { render } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\n\nimport AppLike from 'test/components/AppLike'\n\njest.mock('react-redux', () => ({\n  ...jest.requireActual('react-redux'),\n  useSelector: jest.fn(() => null)\n}))\n\nimport { useCurrentFolderId, useFolderSort } from '@/hooks'\n\njest.mock('@/hooks', () => ({\n  useCurrentFolderId: jest.fn(() => 'io.cozy.files.trash-dir'),\n  useDisplayedFolder: jest.fn(() => ({\n    displayedFolder: { _id: 'trash' },\n    isNotFound: false\n  })),\n  useFolderSort: jest.fn(() => [\n    { attribute: 'updated_at', order: 'desc' },\n    jest.fn()\n  ])\n}))\n\nconst client = createMockClient({})\nclient.query = jest.fn().mockReturnValue({ data: [] })\n\nfunction TestComponent({ onResult }) {\n  const currentFolderId = useCurrentFolderId()\n  const [sortOrder] = useFolderSort(currentFolderId)\n\n  React.useEffect(() => {\n    onResult({ currentFolderId, sortOrder })\n  }, [currentFolderId, sortOrder, onResult])\n\n  return null\n}\n\ndescribe('TrashFolderView', () => {\n  it('uses the updated_at sort for trash folder by default', () => {\n    let result = null\n\n    const handleResult = data => {\n      result = data\n    }\n\n    render(\n      <AppLike client={client}>\n        <TestComponent onResult={handleResult} />\n      </AppLike>\n    )\n\n    expect(result.currentFolderId).toBe('io.cozy.files.trash-dir')\n    expect(result.sortOrder.attribute).toBe('updated_at')\n    expect(result.sortOrder.order).toBe('desc')\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Upload/UploadTypes.ts",
    "content": "export interface FileForQueue {\n  name: string\n  file?: { name: string }\n  isDirectory?: false\n}\n\nexport interface FileFromNative {\n  name: string\n  file: {\n    weblink: null\n    text: null\n    filePath: string\n    contentUri: string\n    subject: null\n    extension: string\n    fileName: string\n    mimeType: string\n    dirId?: string\n    conflictStrategy?: string\n    fromFlagship?: boolean\n  }\n  status: number\n}\n\nexport interface UploadFromFlagship {\n  items?: FileFromNative['file'][]\n  uploadFilesFromFlagship: (folderId: string) => void\n  resetFilesToHandle: () => Promise<void>\n  onClose: () => Promise<void>\n  uploadInProgress: boolean\n}\n"
  },
  {
    "path": "src/modules/views/Upload/UploadUtils.ts",
    "content": "import { WebviewService } from 'cozy-intent'\nimport logger from 'cozy-logger'\n\nimport { RECEIVE_UPLOAD_ERROR, RECEIVE_UPLOAD_SUCCESS } from '@/modules/upload'\nimport type {\n  FileFromNative,\n  FileForQueue\n} from '@/modules/views/Upload/UploadTypes'\n\nexport const generateForQueue = (\n  files: FileFromNative['file'][]\n): FileForQueue[] => {\n  // @ts-expect-error fix file types mismatch\n  return files.map(file => ({ file: file, isDirectory: false }))\n}\n\nexport const onFileUploaded = (\n  data: {\n    file: FileFromNative\n    isSuccess: boolean\n  },\n  dispatch: (arg0: { type: string; file: FileFromNative }) => void\n): void => {\n  if (!data.file) return\n\n  // Status 2 means file status is \"uploaded\", any other status should be considered as an error,\n  // though it is not intended to receive something else than \"uploaded\" (2) or \"error\" (3)\n  // See OsReceiveFileStatus enum in cozy-flagship\n  if (data.file.status === 2) {\n    dispatch({ type: RECEIVE_UPLOAD_SUCCESS, file: data.file })\n  } else {\n    dispatch({ type: RECEIVE_UPLOAD_ERROR, file: data.file })\n  }\n}\n\nexport const shouldRender = (\n  items?: FileFromNative['file'][]\n): items is FileFromNative['file'][] => !!items && items.length > 0\n\nexport const getFilesToHandle = async (\n  webviewIntent: WebviewService\n): Promise<(FileFromNative['file'] & { name: string })[]> => {\n  logger('info', 'getFilesToHandle called')\n\n  const files = (await webviewIntent.call(\n    'getFilesToHandle'\n  )) as unknown as FileFromNative[]\n\n  if (files.length === 0) throw new Error('No files to upload')\n\n  if (files.length > 0) {\n    logger('info', 'getFilesToHandle success')\n\n    return files.map(fileFromNative => ({\n      ...fileFromNative.file,\n      name: fileFromNative.file.fileName\n    }))\n  } else {\n    logger('info', 'getFilesToHandle no files to upload')\n    throw new Error('No files to upload')\n  }\n}\n\nexport const sendFilesToHandle = (\n  filesForQueue: FileForQueue[],\n  webviewIntent: WebviewService,\n  folderId: string\n): void => {\n  const filesToUpload = filesForQueue.map(file => {\n    if (!file.file) throw new Error('No file to upload')\n\n    return {\n      fileOptions: {\n        name: file.file.name,\n        dirId: folderId\n      }\n    }\n  })\n\n  logger('info', 'uploadFilesFromFlagship called')\n\n  void webviewIntent.call('uploadFiles', JSON.stringify(filesToUpload))\n\n  logger('info', 'uploadFilesFromFlagship success')\n}\n"
  },
  {
    "path": "src/modules/views/Upload/UploaderComponent.tsx",
    "content": "import React from 'react'\n\nimport { useQuery } from 'cozy-client'\nimport { IOCozyFile } from 'cozy-client/types/types'\nimport { FixedDialog } from 'cozy-ui/transpiled/react/CozyDialogs'\nimport Spinner from 'cozy-ui/transpiled/react/Spinner'\nimport { useI18n } from 'twake-i18n'\n\nimport { FolderPicker } from '@/components/FolderPicker/FolderPicker'\nimport { File, FolderPickerEntry } from '@/components/FolderPicker/types'\nimport { ROOT_DIR_ID } from '@/constants/config'\nimport { shouldRender } from '@/modules/views/Upload/UploadUtils'\nimport { useUploadFromFlagship } from '@/modules/views/Upload/useUploadFromFlagship'\nimport { buildFileOrFolderByIdQuery } from '@/queries'\n\nconst UploaderComponent = (): JSX.Element | null => {\n  const { t } = useI18n()\n  const { items, uploadFilesFromFlagship, onClose, uploadInProgress } =\n    useUploadFromFlagship()\n\n  const handleConfirm = (folder?: File): void => {\n    if (!folder?._id) {\n      throw new Error('A folder id is required')\n    }\n    uploadFilesFromFlagship(folder._id)\n  }\n\n  const rootFolderQuery = buildFileOrFolderByIdQuery(ROOT_DIR_ID)\n  const rootFolderResult = useQuery(\n    rootFolderQuery.definition,\n    rootFolderQuery.options\n  ) as {\n    data?: IOCozyFile\n  }\n\n  if (shouldRender(items) && rootFolderResult.data) {\n    const fakeFiles: FolderPickerEntry[] = items.map(item => ({\n      _type: 'io.cozy.files',\n      type: 'file',\n      dir_id: item.dirId,\n      name: item.fileName,\n      mime: item.mimeType\n    }))\n\n    return (\n      <FolderPicker\n        currentFolder={rootFolderResult.data}\n        entries={fakeFiles}\n        canCreateFolder={false}\n        onConfirm={handleConfirm}\n        onClose={onClose}\n        isBusy={uploadInProgress}\n        slotProps={{\n          header: {\n            title: t('ImportToDrive.title', { smart_count: fakeFiles.length }),\n            subTitle: t('ImportToDrive.to')\n          },\n          footer: {\n            confirmLabel: t('ImportToDrive.action'),\n            cancelLabel: t('ImportToDrive.cancel')\n          }\n        }}\n      />\n    )\n  }\n\n  // If there are no items to render, we display a spinner with a full screen dialog to hide the UI behind\n  return (\n    <FixedDialog\n      className=\"u-p-0\"\n      open\n      size=\"large\"\n      content={<Spinner size=\"xxlarge\" noMargin middle />}\n    />\n  )\n}\n\nexport { UploaderComponent }\n"
  },
  {
    "path": "src/modules/views/Upload/__mocks__/cozy-intent.ts",
    "content": "import { FileFromNative, FileForQueue } from '../UploadTypes'\n\nexport const webviewIntent = {\n  call: (\n    methodName: string,\n    files?: FileFromNative[] | FileForQueue[]\n  ): Promise<FileFromNative[] | void> => {\n    if (methodName === 'getFilesToHandle') {\n      return Promise.resolve([\n        {\n          fileName: 'test.pdf',\n          type: 'application/pdf',\n          size: 1000,\n          lastModified: 123456789\n        }\n      ])\n    }\n    if (methodName === 'uploadFiles') {\n      return Promise.resolve(files?.map(file => file as FileFromNative))\n    }\n    if (methodName === 'resetFilesToHandle') {\n      return Promise.resolve()\n    }\n    return Promise.resolve()\n  }\n}\n"
  },
  {
    "path": "src/modules/views/Upload/useResumeFromFlagship.spec.tsx",
    "content": "import { render, waitFor } from '@testing-library/react'\nimport React from 'react'\nimport { useDispatch, useSelector } from 'react-redux'\n\nimport { WebviewService } from 'cozy-intent'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\n\nimport { getProcessed, getSuccessful } from '@/modules/upload'\nimport { useResumeUploadFromFlagship } from '@/modules/views/Upload/useResumeFromFlagship'\n\nglobal.jasmine = {\n  // @ts-expect-error - Test will fail if this is not set\n  testPath: ''\n}\n\nconst mockUseDispatch = useDispatch as jest.Mock\nconst mockGetProcessed = getProcessed as jest.Mock\nconst mockGetSuccessful = getSuccessful as jest.Mock\nconst mockUseSelector = useSelector as jest.Mock\nconst mockUseAlert = useAlert as jest.Mock\n\nconst showAlert = jest.fn()\n\njest.mock('cozy-ui/transpiled/react/hooks/useBrowserOffline')\njest.mock('cozy-ui/transpiled/react/providers/Alert', () => ({\n  ...jest.requireActual('cozy-ui/transpiled/react/providers/Alert'),\n  __esModule: true,\n  useAlert: jest.fn()\n}))\n\njest.mock('cozy-intent', () => ({\n  useWebviewIntent: (): Partial<WebviewService> => ({\n    call: () =>\n      Promise.resolve({\n        filesToHandle: [{ name: 'testFile' }]\n      }) as unknown as Promise<boolean>\n  })\n}))\n\njest.mock('react-redux', () => ({\n  useSelector: jest.fn(),\n  useDispatch: jest.fn(),\n  createSelectorHook: jest.fn()\n}))\n\njest.mock('modules/upload', () => ({\n  getUploadQueue: jest.fn(),\n  ADD_TO_UPLOAD_QUEUE: 'ADD_TO_UPLOAD_QUEUE',\n  getProcessed: jest.fn(),\n  getSuccessful: jest.fn()\n}))\n\nconst TestComponent = (): JSX.Element => {\n  useResumeUploadFromFlagship()\n\n  return <div>Test</div>\n}\n\ndescribe('useResumeUploadFromFlagship', () => {\n  const mockDispatch = jest.fn()\n\n  beforeEach(() => {\n    jest.clearAllMocks()\n    mockUseDispatch.mockReturnValue(mockDispatch)\n    mockUseAlert.mockReturnValue({ showAlert })\n  })\n\n  it('should not resume if there is no webview intent', () => {\n    mockGetProcessed.mockReturnValue([])\n    mockUseSelector.mockReturnValue([])\n    mockGetSuccessful.mockReturnValue([])\n\n    render(<TestComponent />)\n\n    expect(showAlert).not.toHaveBeenCalled()\n  })\n\n  it('should not attempt to resume uploads if uploadQueue already has items on initialization', () => {\n    mockGetProcessed.mockReturnValue([])\n    mockGetSuccessful.mockReturnValue([])\n    mockUseSelector.mockReturnValue([{ name: 'testFile' }])\n\n    render(<TestComponent />)\n\n    expect(mockDispatch).not.toHaveBeenCalledWith({\n      type: 'ADD_TO_UPLOAD_QUEUE',\n      files: ''\n    })\n  })\n\n  it('should dispatch files to the upload queue when webviewIntent returns files from hasFilesToHandle', async () => {\n    mockGetProcessed.mockReturnValue([])\n    mockUseSelector.mockReturnValue([])\n    mockGetSuccessful.mockReturnValue([])\n\n    render(<TestComponent />)\n\n    await waitFor(() => {\n      expect(mockDispatch).toHaveBeenCalledWith({\n        type: 'ADD_TO_UPLOAD_QUEUE',\n        files: [{ name: 'testFile' }]\n      })\n    })\n  })\n\n  it('should not call the alert if upload is not finished', async () => {\n    mockGetProcessed.mockReturnValue([])\n    mockGetSuccessful.mockReturnValue([])\n    mockUseSelector.mockReturnValue([{ name: 'testFile' }])\n\n    render(<TestComponent />)\n\n    await waitFor(() => {\n      expect(showAlert).not.toHaveBeenCalled()\n    })\n  })\n\n  it('should not perform any action if webviewIntent returns an error stating \"has not been implemented\"', async () => {\n    mockGetProcessed.mockReturnValue([])\n    mockUseSelector.mockReturnValue([])\n    mockGetSuccessful.mockReturnValue([])\n    const mockError = new Error('has not been implemented')\n    jest.mock('cozy-intent', () => ({\n      useWebviewIntent: (): Partial<WebviewService> => ({\n        call: () => Promise.reject(mockError)\n      })\n    }))\n\n    render(<TestComponent />)\n\n    await waitFor(() => {\n      expect(mockDispatch).not.toHaveBeenCalled()\n    })\n  })\n})\n"
  },
  {
    "path": "src/modules/views/Upload/useResumeFromFlagship.ts",
    "content": "import { useEffect } from 'react'\nimport { useDispatch, useSelector } from 'react-redux'\n\nimport { useClient } from 'cozy-client'\nimport { useWebviewIntent } from 'cozy-intent'\nimport logger from 'cozy-logger'\n\nimport { getErrorMessage } from '@/modules/drive/helpers'\nimport { getUploadQueue, ADD_TO_UPLOAD_QUEUE } from '@/modules/upload'\nimport { FileFromNative } from '@/modules/views/Upload/UploadTypes'\n\nexport const useResumeUploadFromFlagship = (): void => {\n  const client = useClient()\n  const dispatch = useDispatch()\n  const webviewIntent = useWebviewIntent()\n  const uploadQueue = useSelector(getUploadQueue) as FileFromNative[]\n\n  useEffect(() => {\n    const doResumeCheck = async (): Promise<void> => {\n      if (!webviewIntent || uploadQueue.length > 0) return\n\n      try {\n        const { filesToHandle } = (await webviewIntent.call(\n          'hasFilesToHandle'\n        )) as unknown as {\n          filesToHandle: FileFromNative[]\n        }\n\n        if (!filesToHandle || filesToHandle.length === 0) return\n\n        dispatch({\n          type: ADD_TO_UPLOAD_QUEUE,\n          files: filesToHandle\n        })\n      } catch (error) {\n        const errorMessage = getErrorMessage(error)\n        logger('info', `hasFilesToHandle error, ${errorMessage}`)\n\n        // It means we're on a cozy-flagship version that doesn't handle file sharing\n        // In that case we don't want to do anything and just let the upload queue empty\n        if (errorMessage.includes('has not been implemented')) return\n      }\n    }\n\n    void doResumeCheck()\n  }, [client, dispatch, webviewIntent, uploadQueue])\n}\n"
  },
  {
    "path": "src/modules/views/Upload/useUploadFromFlagship.ts",
    "content": "import { useCallback, useEffect, useState } from 'react'\nimport { useDispatch } from 'react-redux'\nimport { useNavigate, useSearchParams } from 'react-router-dom'\n\nimport { useWebviewIntent } from 'cozy-intent'\nimport logger from 'cozy-logger'\nimport { useAlert } from 'cozy-ui/transpiled/react/providers/Alert'\nimport { useI18n } from 'twake-i18n'\n\nimport { getErrorMessage } from '@/modules/drive/helpers'\nimport { ADD_TO_UPLOAD_QUEUE, purgeUploadQueue } from '@/modules/upload'\nimport {\n  FileFromNative,\n  UploadFromFlagship\n} from '@/modules/views/Upload/UploadTypes'\nimport {\n  generateForQueue,\n  getFilesToHandle,\n  sendFilesToHandle\n} from '@/modules/views/Upload/UploadUtils'\n\nexport const useUploadFromFlagship = (): UploadFromFlagship => {\n  const webviewIntent = useWebviewIntent()\n  const [searchParams] = useSearchParams()\n  const [items, setItems] = useState<FileFromNative['file'][]>()\n  const fromFlagshipUpload = searchParams.get('fromFlagshipUpload')\n  const navigate = useNavigate()\n  const [uploadInProgress] = useState<boolean>(false)\n  const dispatch = useDispatch()\n  const { t } = useI18n()\n  const { showAlert } = useAlert()\n\n  const showImportError = useCallback(() => {\n    showAlert({\n      message: t('ImportToDrive.error'),\n      severity: 'error',\n      duration: null,\n      noClickAway: true\n    })\n  }, [showAlert, t])\n\n  useEffect(() => {\n    const asyncGetFilesToHandle = async (): Promise<void> => {\n      if (fromFlagshipUpload && webviewIntent) {\n        try {\n          const files = await getFilesToHandle(webviewIntent)\n          setItems(files)\n          logger('info', 'getFilesToHandle success, setting didFetch = true')\n        } catch (error) {\n          logger(\n            'info',\n            `getFilesToHandle error, setting didFetch = true, ${getErrorMessage(\n              error\n            )}`\n          )\n          showImportError()\n          navigate('/')\n        }\n      }\n    }\n\n    void asyncGetFilesToHandle()\n  }, [fromFlagshipUpload, webviewIntent, navigate, showImportError])\n\n  const uploadFilesFromFlagship = useCallback(\n    (folderId: string) => {\n      if (!items || items.length === 0 || !webviewIntent) return\n\n      const filesForQueue = generateForQueue(items)\n\n      dispatch({\n        type: ADD_TO_UPLOAD_QUEUE,\n        files: filesForQueue\n      })\n\n      try {\n        sendFilesToHandle(filesForQueue, webviewIntent, folderId)\n        navigate(`/folder/${folderId}`)\n      } catch (error) {\n        showImportError()\n        logger('info', `uploadFilesFromNative error, ${getErrorMessage(error)}`)\n        navigate('/')\n      }\n    },\n    [dispatch, items, navigate, webviewIntent, showImportError]\n  )\n\n  const onClose = useCallback(async () => {\n    await webviewIntent?.call('cancelUploadByCozyApp')\n    dispatch(purgeUploadQueue())\n    navigate('/')\n  }, [dispatch, navigate, webviewIntent])\n\n  const resetFilesToHandle = useCallback(async () => {\n    try {\n      logger('info', 'resetFilesToHandle called')\n      await webviewIntent?.call('resetFilesToHandle')\n      logger('info', 'resetFilesToHandle success')\n    } catch (error) {\n      logger('info', `resetFilesToHandle error, ${getErrorMessage(error)}`)\n      // Don't need to show a notification to the user here, just redirect to the home\n      navigate('/')\n    }\n  }, [navigate, webviewIntent])\n\n  return {\n    onClose,\n    uploadInProgress,\n    items,\n    uploadFilesFromFlagship,\n    resetFilesToHandle\n  }\n}\n"
  },
  {
    "path": "src/modules/views/testUtils.jsx",
    "content": "import { generateFile } from 'test/generate'\n\nexport const generateFileFixtures = ({\n  nbFiles = 5,\n  path = '/test',\n  dir_id = 'io.cozy.files.root-dir',\n  updated_at = '2020-05-14T10:33:31.365224+02:00',\n  type = 'file',\n  prefix\n}) => {\n  const filesFixture = Array(nbFiles)\n    .fill(null)\n    .map((x, i) => generateFile({ i, type, path, dir_id, updated_at, prefix }))\n\n  return filesFixture\n}\n\nexport const getByTextWithMarkup = (getByText, text) => {\n  getByText((content, node) => {\n    const hasText = node => node.textContent === text\n    const childrenDontHaveText = Array.from(node.children).every(\n      child => !hasText(child)\n    )\n    return hasText(node) && childrenDontHaveText\n  })\n}\n\n// We need this because MidEllipsis uses control characters (invisible formatting characters)\n// For the purpose of testing it is simpler to strip them from the computed paths\nexport const removeNonASCII = str => str.replace(/[^\\x20-\\x7E]/g, '')\n"
  },
  {
    "path": "src/modules/views/useUpdateDocumentTitle.jsx",
    "content": "import { useMemo, useEffect } from 'react'\n\nimport { useClient, models } from 'cozy-client'\nimport { useI18n } from 'twake-i18n'\n\nimport { TRASH_DIR_PATH } from '@/constants/config'\nimport { makeParentFolderPath } from '@/modules/filelist/helpers'\n\nexport const makeTitle = (file, appFullName, t) => {\n  if (!file) return\n\n  const fileName =\n    file && file.name && !TRASH_DIR_PATH.includes(file.name)\n      ? `${file.name} `\n      : ''\n\n  const parentFolderPath = makeParentFolderPath(file)\n\n  let path = ''\n  if (models.file.isDirectory(file)) {\n    if (file && file.path) {\n      if (file.path.startsWith(TRASH_DIR_PATH)) {\n        const trashSubDirectories = file.path.split(`${TRASH_DIR_PATH}/`)[1]\n        path = trashSubDirectories\n          ? `(${t('Nav.item_trash')}/${trashSubDirectories}) `\n          : `${t('Nav.item_trash')} `\n      } else if (file.path !== '/' && file.path !== `/${file.name}`) {\n        path = `(${file.path.substring(1)}) `\n      }\n    }\n  } else {\n    if (file && parentFolderPath) {\n      if (parentFolderPath.startsWith(TRASH_DIR_PATH)) {\n        const trashSubDirectories = parentFolderPath.split(\n          `${TRASH_DIR_PATH}/`\n        )[1]\n        path = trashSubDirectories\n          ? `(${t('Nav.item_trash')}/${trashSubDirectories}) `\n          : `(${t('Nav.item_trash')}) `\n      } else {\n        path = `(${parentFolderPath.substring(1)}) `\n      }\n    }\n  }\n\n  const separator = fileName || path ? '- ' : ''\n\n  return `${fileName}${path}${separator}${appFullName}`\n}\n\nconst useUpdateDocumentTitle = (file, fetchStatus, customTitle) => {\n  const { t } = useI18n()\n  const client = useClient()\n\n  const appFullName = useMemo(\n    () => `${client.appMetadata.prefix} ${client.appMetadata.name}`,\n    [client.appMetadata]\n  )\n\n  const title = useMemo(() => {\n    if (customTitle) {\n      return `${customTitle} - ${appFullName}`\n    }\n    return makeTitle(file, appFullName, t)\n  }, [file, appFullName, t, customTitle])\n\n  useEffect(() => {\n    const shouldUpdate = customTitle || fetchStatus === 'loaded'\n    if (shouldUpdate && title && title !== document.title) {\n      document.title = title\n    }\n  }, [fetchStatus, title, customTitle])\n}\n\nexport default useUpdateDocumentTitle\n"
  },
  {
    "path": "src/modules/views/useUpdateDocumentTitle.spec.js",
    "content": "import Polyglot from 'node-polyglot'\n\nimport { makeTitle } from './useUpdateDocumentTitle'\n\nimport { TRASH_DIR_PATH } from '@/constants/config'\nimport en from '@/locales/en.json'\n\nconst p = new Polyglot()\np.extend(en)\nconst t = p.t.bind(p)\n\ndescribe('makeTitle', () => {\n  describe('for files', () => {\n    it('should show only the app name', () => {\n      expect(makeTitle({}, 'Cozy Drive', t)).toBe('Cozy Drive')\n    })\n\n    it('should show the file name and app name', () => {\n      expect(makeTitle({ name: 'file.docx' }, 'Cozy Drive', t)).toBe(\n        'file.docx - Cozy Drive'\n      )\n    })\n\n    it('should show the folder name and app name', () => {\n      expect(makeTitle({ path: '/folder/subFolder' }, 'Cozy Drive', t)).toBe(\n        '(folder/subFolder) - Cozy Drive'\n      )\n    })\n\n    it('should not show the folder name and app name if inside an array', () => {\n      expect(makeTitle([{ path: '/folder/subFolder' }], 'Cozy Drive', t)).toBe(\n        'Cozy Drive'\n      )\n    })\n\n    it('should show the file name, folder name and app name', () => {\n      expect(\n        makeTitle(\n          { name: 'file.docx', path: '/folder/subFolder' },\n          'Cozy Drive',\n          t\n        )\n      ).toBe('file.docx (folder/subFolder) - Cozy Drive')\n    })\n\n    it('should show trash folder with human frendly name', () => {\n      expect(\n        makeTitle(\n          { name: 'file.docx', path: `${TRASH_DIR_PATH}/folder` },\n          'Cozy Drive',\n          t\n        )\n      ).toBe('file.docx (Bin/folder) - Cozy Drive')\n    })\n\n    it('should show trash folder with human frendly name even if no subdirectory', () => {\n      expect(\n        makeTitle({ name: 'file.docx', path: TRASH_DIR_PATH }, 'Cozy Drive', t)\n      ).toBe('file.docx (Bin) - Cozy Drive')\n    })\n  })\n\n  describe('for folders', () => {\n    it('should show trash folder with human frendly name even if no subdirectory', () => {\n      expect(\n        makeTitle(\n          { type: 'directory', name: '.cozy_trash', path: TRASH_DIR_PATH },\n          'Cozy Drive',\n          t\n        )\n      ).toBe('Bin - Cozy Drive')\n    })\n\n    it('should show trash folder with human frendly name', () => {\n      expect(\n        makeTitle(\n          {\n            type: 'directory',\n            name: 'folder',\n            path: `${TRASH_DIR_PATH}/folder`\n          },\n          'Cozy Drive',\n          t\n        )\n      ).toBe('folder (Bin/folder) - Cozy Drive')\n    })\n\n    it('should show folder path and app name', () => {\n      expect(\n        makeTitle(\n          { type: 'directory', name: 'subfolder', path: '/folder/subfolder' },\n          'Cozy Drive',\n          t\n        )\n      ).toBe('subfolder (folder/subfolder) - Cozy Drive')\n    })\n\n    it('should not show folder path and app name if inside an array', () => {\n      expect(\n        makeTitle(\n          [{ type: 'directory', name: 'subfolder', path: '/folder/subfolder' }],\n          'Cozy Drive',\n          t\n        )\n      ).toBe('Cozy Drive')\n    })\n\n    it('should show only app name for root directory', () => {\n      expect(\n        makeTitle({ type: 'directory', name: '', path: '/' }, 'Cozy Drive', t)\n      ).toBe('Cozy Drive')\n    })\n\n    it('should not show the path for first level folders', () => {\n      expect(\n        makeTitle(\n          { type: 'directory', name: 'folder', path: '/folder' },\n          'Cozy Drive',\n          t\n        )\n      ).toBe('folder - Cozy Drive')\n    })\n  })\n})\n"
  },
  {
    "path": "src/queries/index.ts",
    "content": "import CozyClient, { Q, QueryDefinition } from 'cozy-client'\nimport { QueryOptions } from 'cozy-client/types/types'\nimport flag from 'cozy-flags'\n\nimport {\n  SHARED_DRIVES_DIR_ID,\n  TRASH_DIR_ID,\n  SETTINGS_DIR_PATH\n} from '@/constants/config'\nimport {\n  DOCTYPE_ALBUMS,\n  DOCTYPE_FILES_SETTINGS,\n  NEXTCLOUD_MIGRATIONS_DOCTYPE\n} from '@/lib/doctypes'\nimport { formatFolderQueryId } from '@/lib/queries'\n\nexport interface QueryConfig {\n  definition: () => QueryDefinition\n  options: QueryOptions\n}\n\ntype QueryBuilder<T = void> = (params: T) => QueryConfig\n\ninterface buildDriveQueryParams {\n  currentFolderId: string\n  type: string\n  sortAttribute: string\n  sortOrder: string\n}\n\n// Needs to be less than 10 minutes, since \"thumbnails\" links\n// are only valid for 10 minutes.\n// cf https://github.com/cozy/cozy-doctypes/blob/master/docs/io.cozy.files.md#files\nconst DEFAULT_CACHE_TIMEOUT_QUERIES = 9 * 60 * 1000\nconst defaultFetchPolicy = CozyClient.fetchPolicies.olderThan(\n  DEFAULT_CACHE_TIMEOUT_QUERIES\n)\n\nexport const buildDriveQuery: QueryBuilder<buildDriveQueryParams> = ({\n  currentFolderId,\n  type,\n  sortAttribute,\n  sortOrder\n}) => ({\n  definition: (): QueryDefinition => {\n    const shouldHideSettingsFolder = flag(\n      'home.wallpaper-personalization.enabled'\n    )\n    const partialIndexFilters: {\n      _id: { $nin: string[] }\n      path?: Record<string, unknown>\n    } = {\n      // This is to avoid fetching shared drives\n      // They are hidden clientside\n      _id: {\n        $nin: [TRASH_DIR_ID, 'io.cozy.files.shared-drives-dir']\n      }\n    }\n\n    if (shouldHideSettingsFolder) {\n      partialIndexFilters.path = {\n        $or: [{ $exists: false }, { $nin: [SETTINGS_DIR_PATH] }]\n      }\n    }\n\n    return Q('io.cozy.files')\n      .where({\n        dir_id: currentFolderId,\n        type,\n        [sortAttribute]: { $gt: null }\n      })\n      .partialIndex(partialIndexFilters)\n      .indexFields(['dir_id', 'type', sortAttribute])\n      .sortBy([\n        { dir_id: sortOrder },\n        { type: sortOrder },\n        { [sortAttribute]: sortOrder }\n      ])\n      .include(['encryption'])\n      .limitBy(100)\n  },\n  options: {\n    as: formatFolderQueryId(type, currentFolderId, sortAttribute, sortOrder),\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\nexport const buildRecentQuery: QueryBuilder = () => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .where({\n        updated_at: {\n          $gt: null\n        }\n      })\n      .partialIndex({\n        type: 'file',\n        trashed: false,\n        dir_id: { $nin: [SHARED_DRIVES_DIR_ID, TRASH_DIR_ID] }\n      })\n      .indexFields(['updated_at'])\n      .sortBy([{ updated_at: 'desc' }])\n      .limitBy(50),\n  options: {\n    as: 'recent-view-query',\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\ninterface buildRecentWithMetadataAttributeQueryParams {\n  attribute: string\n}\n\n// TODO: since this query is almost the same as buildRecentQuery\n// we can probably refactor a bit\n// see https://github.com/cozy/cozy-drive/pull/2193#pullrequestreview-553766674\n/**\n * Returns one file with specific metadata for Recent view\n * Only one file is necessary because it allows us to know whether or not to display\n * the column for this specific metadata (like carbonCopy or electronicSafe).\n * @param {object} params - Params\n * @param {string} params.attribute - Metadata attribute\n */\nexport const buildRecentWithMetadataAttributeQuery: QueryBuilder<\n  buildRecentWithMetadataAttributeQueryParams\n> = ({ attribute }) => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .where({\n        type: 'file',\n        trashed: false,\n        [`metadata.${attribute}`]: true,\n        updated_at: {\n          $gt: null\n        }\n      })\n      .indexFields([`metadata.${attribute}`, 'type', 'trashed', 'updated_at'])\n      .limitBy(1),\n  options: {\n    as: `recent-view-query-with-${attribute}`,\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\nexport const buildParentsByIdsQuery: QueryBuilder<string[]> = ids => ({\n  definition: () => Q('io.cozy.files').getByIds(ids),\n  options: {\n    as: `parents-by-ids-${ids.join('')}`,\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\ninterface buildSharingsQueryParams {\n  ids: string[]\n  enabled?: boolean\n}\n\nexport const buildSharingsQuery: QueryBuilder<buildSharingsQueryParams> = ({\n  ids,\n  enabled = true\n}) => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .getByIds(ids)\n      .sortBy([{ type: 'asc' }, { name: 'asc' }]),\n  options: {\n    as: `sharings-by-ids-${ids.join('')}`,\n    enabled,\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\ninterface buildSharingsWithMetadataAttributeQueryParams {\n  sharedDocumentIds: string[]\n  attribute: string\n}\n\n// TODO: since this query is almost the same as buildSharingsQuery\n// we can probably refactor a bit\n// see https://github.com/cozy/cozy-drive/pull/2193#pullrequestreview-553766674\n/**\n * Returns one file with specific metadata for Sharing view.\n * Only one file is necessary because it allows us to know whether or not to display\n * the column for this specific metadata (like carbonCopy or electronicSafe).\n * @param {object} params - Params\n * @param {array} params.sharedDocumentsIds - Ids of shared documents\n * @param {string} params.attribute - Metadata attribute\n */\nexport const buildSharingsWithMetadataAttributeQuery: QueryBuilder<\n  buildSharingsWithMetadataAttributeQueryParams\n> = ({ sharedDocumentIds, attribute }) => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .getByIds(sharedDocumentIds)\n      .where({ [`metadata.${attribute}`]: true })\n      .limitBy(1),\n  options: {\n    as: `sharings-by-ids-${sharedDocumentIds.join('')}-with-${attribute}`,\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\ninterface buildTrashQueryParams {\n  currentFolderId: string\n  sortAttribute: string\n  sortOrder: string\n  type: string\n  limit?: number\n}\n\nexport const buildTrashQuery: QueryBuilder<buildTrashQueryParams> = ({\n  currentFolderId,\n  sortAttribute,\n  sortOrder,\n  type,\n  limit\n}) => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .where({\n        dir_id: currentFolderId,\n        type,\n        [sortAttribute]: { $gt: null }\n      })\n      .indexFields(['dir_id', 'type', sortAttribute])\n      .sortBy([\n        { dir_id: sortOrder },\n        { type: sortOrder },\n        { [sortAttribute]: sortOrder }\n      ])\n      .limitBy(limit ? limit : 100),\n  options: {\n    as: `trash-${formatFolderQueryId(\n      type,\n      currentFolderId,\n      sortAttribute,\n      sortOrder\n    )}`,\n    fetchPolicy: CozyClient.fetchPolicies.olderThan(\n      DEFAULT_CACHE_TIMEOUT_QUERIES\n    )\n  }\n})\n\ninterface buildSharedDriveQueryParams {\n  currentFolderId: string\n  type: string\n  sortAttribute: string\n  sortOrder: string\n  driveId: string\n  fileId: string\n}\n\nexport const buildSharedDriveQuery: QueryBuilder<\n  buildSharedDriveQueryParams\n> = ({ currentFolderId, type, sortAttribute, sortOrder, driveId }) => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .where({\n        dir_id: currentFolderId,\n        driveId,\n        type,\n        [sortAttribute]: { $gt: null }\n      })\n      .indexFields(['dir_id', 'type', 'driveId', sortAttribute])\n      .sortBy([\n        { dir_id: sortOrder },\n        { driveId: sortOrder },\n        { type: sortOrder },\n        { [sortAttribute]: sortOrder }\n      ])\n      .include(['encryption'])\n      .limitBy(100),\n  options: {\n    as: formatFolderQueryId(\n      type,\n      currentFolderId,\n      sortAttribute,\n      sortOrder,\n      driveId\n    ),\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\nexport const buildMoveOrImportQuery: QueryBuilder<string> = dirId => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .where({\n        dir_id: dirId,\n        type: { $gt: null },\n        name: { $gt: null }\n      })\n      .partialIndex({\n        // This is to avoid fetching shared drives and trash\n        // They are hidden clientside\n        _id: {\n          $nin: [SHARED_DRIVES_DIR_ID, TRASH_DIR_ID]\n        }\n      })\n      .indexFields(['dir_id', 'type', 'name'])\n      .sortBy([{ dir_id: 'asc' }, { type: 'asc' }, { name: 'asc' }])\n      .limitBy(100),\n  options: {\n    as: `moveOrImport-${dirId}`,\n    fetchPolicy: CozyClient.fetchPolicies.olderThan(\n      DEFAULT_CACHE_TIMEOUT_QUERIES\n    )\n  }\n})\n\ninterface buildFileWithSpecificMetadataAttributeQueryParams {\n  currentFolderId: string\n  attribute: string\n}\n\n// TODO: since this query is almost the same as buildDriveQuery\n// we can probably refactor a bit\n// see https://github.com/cozy/cozy-drive/pull/2193#pullrequestreview-553766674\n/**\n * Returns one file with specific metadata.\n * Only one file is necessary because it allows us to know whether or not to display\n * the column for this specific metadata (like carbonCopy or electronicSafe).\n * @param {string} currentFolderId - Id of the current folder\n * @param {string} attribute - Metadata\n */\nexport const buildFileWithSpecificMetadataAttributeQuery: QueryBuilder<\n  buildFileWithSpecificMetadataAttributeQueryParams\n> = ({ currentFolderId, attribute }) => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .where({\n        dir_id: currentFolderId,\n        [`metadata.${attribute}`]: true,\n        type: 'file'\n      })\n      .indexFields(['dir_id', `metadata.${attribute}`, 'type'])\n      .limitBy(1),\n  options: {\n    as: `specific-metadata-${attribute}-for-${currentFolderId}`,\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\nexport const buildFileOrFolderByIdQuery: QueryBuilder<string> = fileId => ({\n  definition: () => Q('io.cozy.files').getById(fileId),\n  options: {\n    as: `io.cozy.files/${fileId}`,\n    fetchPolicy: defaultFetchPolicy,\n    singleDocData: true,\n    enabled: !!fileId\n  }\n})\n\ninterface BuildSharedDriveFileOrFolderByIdQuery {\n  fileId: string\n  driveId: string\n}\n\nexport const buildSharedDriveFileOrFolderByIdQuery: QueryBuilder<\n  BuildSharedDriveFileOrFolderByIdQuery\n> = ({ fileId, driveId }) => ({\n  definition: () => Q('io.cozy.files').getById(fileId).sharingById(driveId),\n  options: {\n    as: `io.cozy.files/${driveId}/${fileId}`,\n    fetchPolicy: defaultFetchPolicy,\n    singleDocData: true,\n    enabled: !!fileId && !!driveId\n  }\n})\n\n// this query should use `getById` instead of `where` as `buildFileOrFolderByIdQuery` does.\n// But in this case, due to a stack limitation, the `file.path` is not returned.\n// As we need the `file.path` we do this trick until the stack is updated\nexport const buildFileWhereByIdQuery: QueryBuilder<string> = fileId => ({\n  definition: () =>\n    Q('io.cozy.files').where({ _id: fileId }).indexFields(['_id']).limitBy(1),\n  options: {\n    as: `io.cozy.files/whereById/${fileId}`,\n    fetchPolicy: defaultFetchPolicy,\n    enabled: !!fileId\n  }\n})\n\nexport const buildAppsQuery: QueryBuilder = () => ({\n  definition: () => Q('io.cozy.apps'),\n  options: {\n    as: `io.cozy.apps`,\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\nexport const buildSettingsByIdQuery: QueryBuilder<string> = id => ({\n  definition: () => Q('io.cozy.settings').getById(id),\n  options: {\n    as: `io.cozy.settings/${id}`,\n    fetchPolicy: defaultFetchPolicy,\n    singleDocData: true\n  }\n})\n\nexport const buildAlbumByIdQuery: QueryBuilder<string> = id => ({\n  definition: () => Q(DOCTYPE_ALBUMS).getById(id),\n  options: {\n    as: `io.cozy.photos.albums/${id}`,\n    fetchPolicy: defaultFetchPolicy,\n    singleDocData: true\n  }\n})\n\nexport const buildFolderByPathQuery: QueryBuilder<string> = path => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .where({\n        type: 'directory',\n        path\n      })\n      .indexFields(['type', ' path']),\n  options: {\n    as: `io.cozy.files/path${path}`,\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\nexport const buildNewSharingShortcutQuery: QueryBuilder = () => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .where({\n        'metadata.sharing.status': 'new',\n        class: 'shortcut',\n        trashed: false\n      })\n      .indexFields(['metadata.sharing.status', 'class', 'trashed']),\n  options: {\n    as: 'io.cozy.files/metadata.sharing.status/new/class/shortcut',\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\nexport const buildTriggersQueryByAccountId: QueryBuilder<\n  string\n> = accountId => ({\n  definition: () =>\n    Q('io.cozy.triggers')\n      .where({\n        'message.account': accountId\n      })\n      .indexFields(['message.account']),\n  options: {\n    as: `${'io.cozy.triggers'}/accounts/${accountId}`,\n    enabled: Boolean(accountId),\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\ninterface buildKonnectorsQueryByIdParams {\n  id: string\n  enabled?: boolean\n}\n\nexport const buildKonnectorsQueryById: QueryBuilder<\n  buildKonnectorsQueryByIdParams\n> = ({ id, enabled = true }) => ({\n  definition: () => Q('io.cozy.konnectors').getById(id),\n  options: {\n    as: `io.cozy.konnectors/${id}`,\n    fetchPolicy: defaultFetchPolicy,\n    enabled\n  }\n})\n\nexport const buildTriggersQueryByKonnectorSlug: QueryBuilder<\n  string\n> = slug => ({\n  definition: () =>\n    Q('io.cozy.triggers')\n      .where({\n        'message.konnector': slug\n      })\n      .indexFields(['message.konnector']),\n  options: {\n    as: `io.cozy.triggers/slug/${slug}`,\n    fetchPolicy: defaultFetchPolicy,\n    enabled: Boolean(slug)\n  }\n})\n\ninterface buildNextcloudFolderQueryParams {\n  sourceAccount?: string\n  path: string\n}\n\nexport const buildNextcloudFolderQuery: QueryBuilder<\n  buildNextcloudFolderQueryParams\n> = ({ sourceAccount, path }) => ({\n  definition: () =>\n    Q('io.cozy.remote.nextcloud.files')\n      .where({\n        'cozyMetadata.sourceAccount': sourceAccount,\n        parentPath: path\n      })\n      .indexFields(['cozyMetadata.sourceAccount', 'parentPath']),\n  options: {\n    as: `io.cozy.remote.nextcloud.files/sourceAccount/${\n      sourceAccount ?? 'unknown'\n    }/path${path}`,\n    fetchPolicy: defaultFetchPolicy,\n    enabled: !!sourceAccount && !!path\n  }\n})\n\ninterface buildNextcloudShortcutQueryParams {\n  sourceAccount: string\n}\n\nexport const buildNextcloudShortcutQuery: QueryBuilder<\n  buildNextcloudShortcutQueryParams\n> = ({ sourceAccount }) => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .where({\n        'cozyMetadata.sourceAccount': sourceAccount\n      })\n      .partialIndex({\n        'cozyMetadata.createdByApp': 'nextcloud'\n      })\n      .indexFields(['cozyMetadata.sourceAccount'])\n      .limitBy(1),\n  options: {\n    as: `io.cozy.files/createdByApp/nextcloud/sourceAccount/${sourceAccount}`,\n    fetchPolicy: defaultFetchPolicy,\n    enabled: !!sourceAccount,\n    singleDocData: true\n  }\n})\n\ninterface buildFavoritesQueryParams {\n  sortAttribute: string\n  sortOrder: string\n}\n\nexport const buildFavoritesQuery: QueryBuilder<buildFavoritesQueryParams> = ({\n  sortAttribute,\n  sortOrder\n}) => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .where({\n        [sortAttribute]: { $gt: null }\n      })\n      .partialIndex({\n        'cozyMetadata.favorite': true,\n        path: { $or: [{ $exists: false }, { $regex: '^(?!/.cozy_trash)' }] },\n        trashed: { $or: [{ $exists: false }, { $eq: false }] },\n        driveId: { $exists: false }\n      })\n      .indexFields([sortAttribute])\n      .sortBy([{ [sortAttribute]: sortOrder }]),\n  options: {\n    as: 'io.cozy.files/metadata.favorite/true',\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\nexport const buildExternalDrivesQuery: QueryBuilder<\n  buildFavoritesQueryParams\n> = ({ sortAttribute, sortOrder }) => ({\n  definition: () =>\n    Q('io.cozy.files')\n      .where({\n        [sortAttribute]: { $gt: null }\n      })\n      .partialIndex({\n        'cozyMetadata.createdByApp': 'nextcloud'\n      })\n      .indexFields([sortAttribute])\n      .sortBy([{ [sortAttribute]: sortOrder }]),\n  options: {\n    as: 'io.cozy.files/metadata.createdByApp/nextcloud',\n    fetchPolicy: defaultFetchPolicy\n  }\n})\n\ninterface buildMagicFolderQueryParams {\n  id: string\n  enabled?: boolean\n}\n\nexport const buildMagicFolderQuery: QueryBuilder<\n  buildMagicFolderQueryParams\n> = ({ id, enabled = false }) => ({\n  definition: () => Q('io.cozy.files').getById(id),\n  options: {\n    as: 'io.cozy.files/' + id,\n    fetchPolicy: defaultFetchPolicy,\n    singleDocData: false,\n    enabled\n  }\n})\n\ninterface buildNextcloudTrashFolderQueryParams {\n  sourceAccount?: string\n  path: string\n}\n\nexport const buildNextcloudTrashFolderQuery: QueryBuilder<\n  buildNextcloudTrashFolderQueryParams\n> = ({ sourceAccount, path }) => ({\n  definition: () =>\n    Q('io.cozy.remote.nextcloud.files')\n      .where({\n        'cozyMetadata.sourceAccount': sourceAccount,\n        parentPath: path,\n        trashed: true\n      })\n      .indexFields(['cozyMetadata.sourceAccount', 'parentPath', 'trashed']),\n  options: {\n    as: `io.cozy.remote.nextcloud.files/sourceAccount/${\n      sourceAccount ?? 'unknown'\n    }/path${path}/trashed`,\n    fetchPolicy: defaultFetchPolicy,\n    enabled: !!sourceAccount && !!path\n  }\n})\n\nexport const getAppSettingQuery: QueryConfig = {\n  definition: () => Q(DOCTYPE_FILES_SETTINGS),\n  options: {\n    as: DOCTYPE_FILES_SETTINGS\n  }\n}\n\ninterface buildSharedDriveFolderQueryParams {\n  driveId: string\n  folderId: string\n}\n\ninterface buildSharedDriveIdQueryParams {\n  driveId: string\n}\n\nexport const buildSharedDriveFolderQuery: QueryBuilder<\n  buildSharedDriveFolderQueryParams\n> = ({ driveId, folderId }) => ({\n  definition: () => Q('io.cozy.files').getById(folderId).sharingById(driveId),\n  options: {\n    as: `io.cozy.files/driveId/${driveId}/folderId/${folderId}`,\n    // fetchPolicy: defaultFetchPolicy, // FIXME we do not use cache here to get the \"included\" part of the result of the query\n    // see https://github.com/cozy/cozy-client/issues/1620\n    enabled: !!driveId && !!folderId,\n    singleDocData: true\n  }\n})\n\nexport const buildSharedDriveIdQuery: QueryBuilder<\n  buildSharedDriveIdQueryParams\n> = ({ driveId }) => ({\n  definition: () => Q('io.cozy.sharings').getById(driveId),\n  options: {\n    as: `io.cozy.sharings/driveId/${driveId}`,\n    fetchPolicy: defaultFetchPolicy,\n    singleDocData: true,\n    enabled: !!driveId\n  }\n})\n\nexport const buildRunningMigrationQuery: QueryBuilder = () => ({\n  definition: () =>\n    Q(NEXTCLOUD_MIGRATIONS_DOCTYPE)\n      .where({ status: 'running' })\n      .indexFields(['status', 'cozyMetadata.createdAt'])\n      .sortBy([{ status: 'desc' }, { 'cozyMetadata.createdAt': 'desc' }])\n      .limitBy(1),\n  options: { as: `${NEXTCLOUD_MIGRATIONS_DOCTYPE}/running` }\n})\n"
  },
  {
    "path": "src/store/__mocks__/configureStore.js",
    "content": "module.exports = () => ({\n  getState: jest.fn(() => ({})),\n  dispatch: jest.fn()\n})\n"
  },
  {
    "path": "src/store/configureStore.js",
    "content": "import { compose, createStore, applyMiddleware } from 'redux'\nimport { createLogger } from 'redux-logger'\nimport thunkMiddleware from 'redux-thunk'\n\nimport flag from 'cozy-flags'\n\nimport createRootReducer from './rootReducer'\n\n/**\n * Creates the redux store\n *\n * Contains cozy-client's state and router state\n *\n * @param  {Object} options Options\n * @param  {Object} options.client CozyClient\n * @param  {Object} options.t Polygot t function\n *\n * @return {ReduxStore}\n */\nconst configureStore = options => {\n  const { client, t, initialState = {}, setStoreToClient = true } = options\n\n  const middlewares = [thunkMiddleware.withExtraArgument({ client, t })]\n\n  if (flag('drive.logger')) {\n    middlewares.push(createLogger(loggerOptions()))\n  }\n\n  // Enable Redux dev tools\n  const composeEnhancers =\n    (flag('debug') && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose\n\n  const rootReducer = createRootReducer(client)\n\n  const store = createStore(\n    rootReducer,\n    initialState,\n    composeEnhancers(applyMiddleware(...middlewares))\n  )\n\n  if (setStoreToClient) {\n    client.setStore(store)\n  }\n\n  return store\n}\n\nconst loggerOptions = () =>\n  flag('debug')\n    ? {}\n    : {\n        level: {\n          prevState: false,\n          nextState: false\n        }\n      }\n\nexport default configureStore\n"
  },
  {
    "path": "src/store/persistedState.js",
    "content": "import localforage from 'localforage'\n\nimport logger from '@/lib/logger'\n// We had some settings that were persisted outside of mobile.settings prior to 1.8.1\n// TODO: fix me\n// eslint-disable-next-line no-prototype-builtins\nconst shouldMigrateSettings = state => state.hasOwnProperty('settings')\n\nconst migrateSettings = async prevState => {\n  const { client, offline } = prevState.settings\n  const { authorized, token, serverUrl, backupImages, analytics, wifiOnly } =\n    prevState.mobile.settings\n  const { revoked } = prevState.mobile.authorization || { revoked: false }\n  const newState = {\n    mobile: {\n      authorization: {\n        authorized,\n        revoked,\n        client,\n        token\n      },\n      settings: {\n        offline,\n        serverUrl,\n        backupImages,\n        analytics,\n        wifiOnly\n      },\n      mediaBackup: prevState.mobile.mediaBackup\n    },\n    availableOffline: prevState.availableOffline\n  }\n  await localforage.setItem('state', newState)\n  logger.info('Migrated persisted settings')\n  logger.info('Previously persisted state: ', prevState)\n  logger.info('New persisted state: ', newState)\n  return newState\n}\n\nexport const loadState = async () => {\n  try {\n    const persistedState = await localforage.getItem('state')\n    if (persistedState === null) {\n      return undefined\n    }\n    if (shouldMigrateSettings(persistedState)) {\n      logger.warn('Migrating persisted settings')\n      const newState = await migrateSettings(persistedState)\n      return newState\n    }\n    return persistedState\n  } catch (err) {\n    logger.warn(err)\n    return undefined\n  }\n}\n\nexport const saveState = async state => {\n  try {\n    await localforage.setItem('state', state)\n  } catch (err) {\n    logger.warn(err)\n  }\n}\n\nexport const resetPersistedState = () => localforage.clear()\n"
  },
  {
    "path": "src/store/rootReducer.js",
    "content": "import { combineReducers } from 'redux'\n\nimport { default as ui } from '@/lib/react-cozy-helpers'\nimport { default as rename } from '@/modules/drive/rename'\nimport { default as filelist } from '@/modules/filelist/duck'\nimport { default as view } from '@/modules/navigation/duck'\n// TODO: Get rid of this, local state would be better\nimport { default as upload } from '@/modules/upload'\n\n// Per Dan Abramov: https://stackoverflow.com/questions/35622588/how-to-reset-the-state-of-a-redux-store/35641992#35641992\nconst createRootReducer = client => {\n  const baseReducers = {\n    ui,\n    view,\n    filelist,\n    upload,\n    rename\n  }\n\n  const reducers = {\n    ...baseReducers,\n    cozy: client.reducer()\n  }\n\n  const appReducer = combineReducers(reducers)\n\n  const rootReducer = (state, action) => {\n    return appReducer(state, action)\n  }\n\n  return rootReducer\n}\n\nexport default createRootReducer\n"
  },
  {
    "path": "src/styles/actionmenu.styl",
    "content": ".fil-mobileactionmenu-file-name\n    overflow hidden\n    text-overflow ellipsis\n    white-space   nowrap\n    max-width 70%\n\n.fil-mobileactionmenu-file-ext\n    color var(--secondaryTextColor)\n\n.fil-mobileactionmenu-category\n    padding-left rem(4)\n    line-height rem(18)\n"
  },
  {
    "path": "src/styles/coz-bar-size.styl",
    "content": "$coz-bar-size = 3rem\n"
  },
  {
    "path": "src/styles/dropzone.styl",
    "content": "@require 'settings/z-index.styl'\n\n.fil-dropzone-active\n    &::after\n        content ''\n        position absolute\n        display block\n        top 0\n        left 0\n        width 100%\n        height 100%\n        background-color rgba(0, 0, 0, .3)\n        z-index 1\n\n.fil-dropzone-teaser\n    position  absolute\n    z-index   $overlay-index\n    left      50%\n    transform translateX(-50%)\n    bottom    2rem\n    display   flex\n    flex-direction column\n    align-items    center\n\n@keyframes pulse\n    from\n        transform scale3d(1, 1, 1)\n    50%\n        transform scale3d(1.25, 1.25, 1.25)\n    to\n        transform scale3d(1, 1, 1)\n\n.fil-dropzone-teaser-claudy\n    width  3.5em\n    height 3.5em\n    margin-bottom 1rem\n    border-radius 100%\n    border 0\n    box-shadow 0 1px 3px 0 rgba(50, 54, 63, .19), 0 6px 18px 0 rgba(50, 54, 63, .39)\n    animation-name pulse\n    animation-duration 2s\n    animation-iteration-count infinite\n    cursor pointer\n    outline 0\n    display flex\n    align-items center\n    justify-content center\n    background-color var(--dodgerBlue) // this is the Cozy logo, shouldn't be theme responsive\n\n.fil-dropzone-teaser-content\n    background-color var(--dodgerBlue) // shouldn't be theme responsive\n    border-radius 2px\n    text-align center\n    color var(--white) // shouldn't be theme responsive\n    padding 1rem\n    box-shadow 0 1px 3px 0 rgba(50, 54, 63, .19), 0 6px 18px 0 rgba(50, 54, 63, .39)\n\n    & > p\n        margin-top 0\n\n    .fil-dropzone-teaser-folder\n        font-weight bold\n"
  },
  {
    "path": "src/styles/filelist.styl",
    "content": "@require 'components/table.styl'\n@require 'settings/z-index.styl'\n\n.fil-file-list-container\n    &:focus-visible\n        outline 0\n\n.fil-content-cell\n    display flex\n\n.fil-content-row\n    &:hover\n        .fil-content-file-select\n            opacity .5\n\n    &--center\n        justify-content center\n\n.fil-content-row-selected\n    @extend $table-row-selected\n\n.fil-content-row-actioned\n    background-color var(--contrastBackgroundColor)\n\n.fil-content-row-disabled\n    opacity .4\n    cursor default\n\n    &:hover\n        background inherit\n\n.fil-content-row-bigger\n    height rem(128)\n\n.fil-content-head-grid-view\n    display none\n\n.fil-content-column\n    border 0\n    position relative\n    margin .1rem auto\n    padding 1.2rem\n    text-align center\n    &:hover\n        background-color var(--contrastBackgroundColor)\n        .fil-content-file-select\n            opacity 1\n\n        .fil-content-file-action\n            opacity 1\n\n    &.fil-content-column-virtualized\n        box-sizing border-box\n        height 100%\n\n    .fil-content-file-select\n        position absolute\n        top 0\n        left 0\n        z-index $popover-index\n\n    .fil-content-file-action\n        position absolute\n        top 0\n        right .1rem\n        padding 0\n        margin 0\n        transform rotate(-90deg) translateY(-100%)\n        transform-origin top right\n        opacity 0\n\n    &--center\n        align-items center\n\n.fil-content-grid-item\n    margin .4rem\n\n.fil-content-column-selected\n    @extend $table-row-selected\n\n    .fil-content-file-select\n        opacity 1\n\n.fil-content-column-disabled\n    opacity .4\n    cursor default\n\n    &:hover\n        background inherit\n\n\n.fil-content-column-actioned\n    background-color var(--contrastBackgroundColor)\n\n\n    &:hover\n        background inherit\n\n.fil-content-ext\n    color var(--secondaryTextColor)\n\n.fil-content-header\n    &:first-child\n        padding .5rem\n\n.fil-content-header--capitalize\n    text-transform capitalize\n\n.fil-content-mobile-head\n    display none\n\n.fil-content-mobile-header\n    padding .5rem\n\n.fil-content-header-sortableasc:hover:after\n.fil-content-header-sortabledesc:hover:after\n.fil-content-header-sortasc:after\n.fil-content-header-sortdesc:after\n    display inline-block\n    content ''\n    margin-left .5rem\n    width .75rem\n    height .75rem\n\n.fil-content-header-sortableasc:hover:after\n    background transparent embedurl('../assets/icons/icon-arrow-up.svg') center .0625rem no-repeat\n.fil-content-header-sortabledesc:hover:after\n    background transparent embedurl('../assets/icons/icon-arrow-down.svg') center .0625rem no-repeat\n.fil-content-header-sortasc:after\n    background transparent embedurl('../assets/icons/icon-arrow-up-grey.svg') center .0625rem no-repeat\n.fil-content-header-sortdesc:after\n    background transparent embedurl('../assets/icons/icon-arrow-down-grey.svg') center .0625rem no-repeat\n\n.fil-content-header-sortdesc\n.fil-content-header-sortasc\n    color var(--primaryTextColor)\n\n.fil-content-header-sortdesc, .fil-content-header-sortasc, .fil-content-header-sortabledesc, .fil-content-header-sortableasc\n    cursor pointer\n\ncolumn-width-large = 40%\ncolumn-width-medium = 20%\ncolumn-width-small = 15%\ncolumn-width-thumbnail = 3rem\ncolumn-width-thumbnail-bigger = 7rem\n\n.fil-content-file\n    flex 0 1 column-width-large\n    user-select none\n    background-position 0 center\n    background-repeat no-repeat\n    background-size rem(32) rem(32)\n\n    &-openable\n        cursor pointer\n\n.fil-content-header.fil-content-file\n    padding-left 0\n\n// @stylint off\n.fil-content-cell\n    &.fil-content-file\n        // When the cell is a filename (or new a directory name input), we don't want flex to shrink it\n        flex 0 0 \"calc(%s - %s)\" % (column-width-large column-width-thumbnail)\n        display flex\n        align-items center\n        padding   0 2rem 0 0\n        font-size      1rem\n        line-height    1.3\n        white-space    nowrap\n        overflow       hidden\n        color var(--primaryTextColor)\n        height 100%\n\n    &.fil-content-file-select\n        padding 0\n\n.fil-content-row-bigger\n    .fil-content-cell.fil-content-file\n        flex 0 1 \"calc(%s - %s)\" % (column-width-large column-width-thumbnail-bigger)\n\n    .fil-content-file-action\n        margin-right 0\n\n.fil-content-grid-view.fil-content-cell\n    white-space normal\n    height auto\n    padding-right 0\n    margin-top 0.6rem\n    text-align center\n    position relative\n\n    .fil-content-status\n        position absolute\n        bottom 1rem\n        right -.1rem\n        padding 0\n\n    .fil-file-filename-and-ext\n        display -webkit-box\n        -webkit-line-clamp 2\n        -webkit-box-orient vertical\n\n        overflow hidden\n        text-overflow ellipsis\n        white-space normal\n\n        max-width 6rem\n\n    .fil-file-path\n        max-width 6rem\n        margin 0 auto\n\n    .fil-file-description\n        max-width 6rem\n        overflow hidden\n\n.fil-file-thumbnail\n    position relative\n    flex 0 0 column-width-thumbnail - 1rem //width without padding\n    svg\n        display block\n\n    &--spinner\n        margin 0 .25rem !important // to override default cozy-ui margin\n\n.fil-file-thumbnail-image\n    object-fit cover\n\n.fil-content-shared\n    position    absolute\n    bottom .8rem\n    right .6rem\n\n.fil-content-shared-grid\n    position    absolute\n    bottom 0\n    right 0\n\n.fil-content-shared-vz\n    position    absolute\n    bottom 3px\n    right -6px\n\n.fil-content-date\n.fil-content-size\n    flex 0 0 column-width-small\n\n.fil-content-narrow\n    flex 0 0 column-width-thumbnail\n    text-align center\n\n.fil-content-status\n.fil-content-header-status\n    flex 0 0 column-width-medium\n\n    .fil-content-offline\n        display none\n        justify-content center\n        align-items center\n        width 1.25rem\n        margin-left 1rem\n        height 1.25rem\n        background-color var(--malachite)\n        border-radius 50%\n\n.fil-content-header-sharing-shortcut\n.fil-content-sharing-shortcut\n    width 1rem\n    padding 0\n    flex-shrink 0\n\n.fil-content-file-action\n.fil-content-header-action\n    display flex\n    justify-content flex-end\n    flex-shrink 0\n    width 3.8rem\n    padding-right .8rem\n    margin-right 2rem\n\n.fil-content-header-action\n    button\n        opacity 1\n        color var(--actionColorActive)\n\n.fil-content-file-select\n    flex       0 0 2rem\n    opacity    0\n    padding    0\n    text-align center\n    font-size  .875rem\n    cursor     default\n\n    [data-input=\"checkbox\"] label\n        cursor default\n        padding 0\n\n.fil-content-file-action\n    opacity     0\n\n.fil-content-body--selectable\n    .fil-content-file-select\n        opacity .5\n\n.fil-content-row-selected\n    &:hover\n        .fil-content-file-select\n            opacity 1\n\n    .fil-content-file-select\n        opacity 1\n\n.fil-content-row-actioned\n    .fil-content-file-action\n        opacity 1\n\n.fil-file\n    display flex\n    flex-direction column\n    justify-content center\n    width 100%\n\n.fil-file-filename\n    flex 0 0 1.3rem\n\n.fil-file-filename-wrapper\n    display table\n    width   100%\n    border-collapse collapse\n\n    .fil-file-filename-and-ext\n        display table-cell\n        max-width 1px\n        width 100%\n        overflow hidden\n        text-overflow ellipsis\n        white-space nowrap\n\n    .fil-file-filename-spinner\n        display table-cell\n        white-space nowrap\n\n.fil-file-description\n    display flex\n    justify-content flex-start\n    align-items baseline\n\n    &--path\n        display contents !important // to take content width\n\n.fil-file-certifications\n    flex-shrink 0\n    color var(--actionColorActive)\n\n    &--separator\n        margin-left .25rem\n\n    &--icon\n        width rem(10)\n        height rem(10)\n\n.fil-file-path\n.fil-file-infos\n.fil-file-description--path\n    color var(--actionColorActive)\n    font-size .75rem\n    text-decoration none\n\n.fil-file-path\n.fil-file-infos\n    flex 0 0 1rem\n    position relative\n    overflow hidden\n    text-overflow ellipsis\n    white-space nowrap\n\n    &:hover,\n    &:focus\n        text-decoration underline\n\n.fil-file-infos\n    display none\n\n.fil-file-shared\n    color var(--actionColorActive)\n    font-size .75rem\n\n.fil-file-shared-icon\n    margin-right .2rem\n\n@keyframes placeHolderShimmer\n    0%\n        background-position -20rem 0\n    80%\n        background-position 20rem 0\n    80.1%\n        background-position -20rem 0\n    100%\n        background-position -20rem 0\n\n.fil-content-file-placeholder\n    animation-duration        2s\n    animation-iteration-count infinite\n    animation-name            placeHolderShimmer\n    animation-timing-function linear\n    background-position -20rem 0\n    background-image linear-gradient(to right, var(--contrastBackgroundColor) 0%, white 50%, var(--contrastBackgroundColor) 100%)\n    background-size 20rem 2.5rem\n    background-repeat no-repeat\n    background-color var(--contrastBackgroundColor)\n    border-radius .15rem\n    height   .75rem\n    max-width    100%\n    position relative\n\n.fil-content-file .fil-content-file-placeholder:before\n    content \"\"\n    display inline-block\n    width   2rem\n    height   2rem\n    background inherit\n    animation inherit\n    border-radius inherit\n    position absolute\n    right 100%\n    top -80%\n    margin-right 1rem\n\n.fil-content-file .fil-content-file-placeholder:before\n    animation-delay 0s\n\n.fil-content-file .fil-content-file-placeholder\n    animation-delay .1s\n\n.fil-content-date .fil-content-file-placeholder\n    animation-delay .5s\n\n.fil-content-size .fil-content-file-placeholder\n    animation-delay .7s\n\n.fil-content-status .fil-content-file-placeholder\n    animation-delay 1.1s\n\n.fil-content-sharestatus\n    cursor pointer\n    &--disabled\n        cursor default\n\n.fil-content-file-action\n    opacity 1\n    &--disabled\n        opacity .4\n        button\n            cursor default\n\n.fil-content-row-actioned\n    .fil-content-file-action\n        button\n            background var(--actionColorDisabled)\n            opacity 1\n\n.fil-content-body\n    padding-bottom calc(56px + 4rem) // Claudy Fab size + margins around\n\n// -- responsive\n\n+medium-screen()\n    .fil-content-table-selection\n        .fil-content-body\n            margin-bottom 3rem\n\n    .fil-content-body--withFabActive\n        padding-bottom calc(56px + 2rem) // Fab size + margins around\n\n+small-screen()\n// @stylint on\n    .fil-content-body:not(.fil-content-body--withFabActive)\n        padding-bottom 0\n\n    .fil-content-row\n        height 4rem\n\n    .fil-content-row-bigger\n        height rem(128)\n\n    .fil-content-head\n    .fil-content-date\n    .fil-content-size\n        display none\n\n    .fil-content-mobile-head\n        display flex\n        flex-basis auto\n\n    .fil-content-mobile-header.fil-content-header-sortasc\n    .fil-content-mobile-header.fil-content-header-sortdesc\n        flex 1 1 auto\n\n    .fil-content-header-action\n    .fil-content-file-action\n        padding-right .2rem\n        margin-right 1rem\n        flex 0\n        box-sizing content-box\n        width 100%\n\n    .fil-content-cell.fil-content-file\n    .fil-content-row-bigger .fil-content-cell.fil-content-file\n        flex 1 1 auto\n\n    .fil-content-status\n        display    block\n        flex       0 0 auto\n        padding    0\n\n        .fil-content-offline\n            display inline-flex\n\n        .fil-content-sharestatus\n            display none\n\n    .fil-content-cell\n        &.fil-content-status\n            padding 0\n\n        &.fil-content-file\n            padding-right 0\n\n        &.fil-content-file-select\n            display none\n            padding 0 0 0 .25rem\n\n    .fil-content-body--selectable\n        .fil-content-file-select\n            display  block\n            flex     0 0 5%\n\n        .fil-content-file-action\n            opacity 0\n\n        .fil-file-thumbnail\n            padding .875rem 1rem .875rem .25rem\n\n    .fil-file-infos\n        display flex\n\n    .fil-file-path\n        &:hover,\n        &:focus\n            text-decoration none\n\n    .fil-content-column\n        width -webkit-fill-available\n        display flex\n        flex-direction column\n        align-items center\n\n        &:nth-child(odd)\n            margin-right .3rem\n\n        .fil-content-file-select\n            top 0\n            left 0\n\n        .fil-content-file-action\n            opacity 1\n\n        .fil-file-infos\n            display none\n"
  },
  {
    "path": "src/styles/filenameinput.styl",
    "content": "@require 'components/forms.styl'\n\n.fil-file-name-input\n    @extend $form-text\n    display table-cell\n    width 100%\n    user-select text\n\n    input[type=text]\n        width   100%\n        padding .375rem\n\n        &:focus\n            background-color  var(--paperBackgroundColor)\n"
  },
  {
    "path": "src/styles/folder-customizer.styl",
    "content": ".iconColorPopper\n    z-index 80\n\n.iconColorPaper\n    max-width 168px\n\n.noneIconFrame\n    border 1px solid var(--secondaryTextColor)\n\n.foldercustomizer\n    &-dialog\n        max-height 50vh // no proper max height value in cozy-ui styleguide\n\n    &-tabs-container\n        min-height 0 // needed to have scrollbar inside the dialog\n\n    &-icons-container\n        overflow-y auto\n        overflow-x hidden\n"
  },
  {
    "path": "src/styles/folder-picker.styl",
    "content": ".icon-shared\n    position absolute\n    bottom -.375rem\n    right -.375rem\n"
  },
  {
    "path": "src/styles/folder-view.styl",
    "content": ".fil-folder-body-grid\n    display grid\n    grid-template-columns repeat(auto-fill, minmax(8.4rem, 1fr))\n    padding .5rem\n"
  },
  {
    "path": "src/styles/main.styl",
    "content": "@require 'settings/breakpoints.styl'\n\ndiv:focus\n    outline 0\n\n.center-layout\n    display flex\n    width 100%\n\n    +medium-screen()\n        min-height 100vh\n        padding 0 2rem\n"
  },
  {
    "path": "src/styles/toolbar.styl",
    "content": "@require 'components/button.styl'\n@require 'settings/breakpoints.styl'\n@require 'settings/z-index.styl'\n@require 'utilities/display.styl'\n\n.fil-toolbar-files\n.fil-toolbar-trash\n    margin-left auto\n    display     flex\n"
  },
  {
    "path": "src/styles/topbar.styl",
    "content": "@require 'components/forms.styl'\n@require 'settings/breakpoints.styl'\n@require 'settings/z-index.styl'\n\n.fil-topbar\n    display     flex\n    align-items center\n    margin-right 2rem\n    margin-left 2rem\n    flex 0 0 auto\n    min-height 2rem\n\n    +small-screen()\n        position relative\n        margin-right 0\n        margin-left 0\n        padding-right .5rem\n        padding-left .5rem\n        min-height 2rem\n\n        &.hidden-mobile\n            display none\n\n.fil-tab-item\n    &.fil-tab-item--selected\n        .fil-tab-icon\n            path\n                fill var(--primaryColor)\n\n    .fil-tab-icon\n        margin-right .5rem\n\n        path\n            fill: var(--shadow7)\n"
  },
  {
    "path": "src/targets/browser/index.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"{{.Locale}}\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <title><%= htmlPlugin.options.title %></title>\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/assets/apple-touch-icon.png\" />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      href=\"/assets/favicon-32x32.png\"\n      sizes=\"32x32\"\n    />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      href=\"/assets/favicon-16x16.png\"\n      sizes=\"16x16\"\n    />\n    <link rel=\"manifest\" href=\"/assets/manifest.json\" crossOrigin=\"use-credentials\" />\n    <link rel=\"mask-icon\" href=\"/assets/safari-pinned-tab.svg\" color=\"#5bbad5\" />\n    <meta name=\"theme-color\" content=\"#ffffff\" />\n    <meta name=\"color-scheme\" content=\"light dark\" />\n    <meta\n      name=\"viewport\"\n      content=\"width=device-width, height=device-height, initial-scale=1, viewport-fit=cover\"\n    />\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"//{{.Domain}}/assets/fonts/fonts.css\">\n    {{.CozyFonts}}\n    <% htmlPlugin.files.css.forEach(function(file) { %>\n      <link rel=\"stylesheet\" href=\"<%- file %>\" />\n    <% }); %>\n    {{.ThemeCSS}}\n  </head>\n  <body>\n    <noscript>You need to enable JavaScript to run this app.</noscript>\n    <div role=\"application\" data-cozy=\"{{.CozyData}}\"></div>\n    <% htmlPlugin.files.js.forEach(function(file) { %>\n      <script src=\"<%- file %>\"></script>\n    <% }); %>\n  </body>\n</html>\n"
  },
  {
    "path": "src/targets/browser/index.jsx",
    "content": "/* eslint-disable import/order */\n\n// cozy-ui css import should be done before any other import\n// otherwise the themes will not be supplied and the app crashes\nimport 'cozy-ui/transpiled/react/stylesheet.css'\nimport 'cozy-ui/dist/cozy-ui.utils.min.css'\nimport 'cozy-ui-plus/dist/stylesheet.css'\nimport 'cozy-viewer/dist/stylesheet.css'\nimport 'cozy-bar/dist/stylesheet.css'\nimport 'cozy-sharing/dist/stylesheet.css'\n\n// Uncomment to activate why-did-you-render\n// https://github.com/welldone-software/why-did-you-render\n// import './wdyr'\n\nimport 'whatwg-fetch'\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\nimport { HashRouter } from 'react-router-dom'\n\nimport setupApp from './setupAppContext'\nimport App from '@/components/App/App'\nimport AppRoute from '@/modules/navigation/AppRoute'\n\n// ambient styles\nimport styles from '@/styles/main.styl' // eslint-disable-line no-unused-vars\n\nconst AppComponent = props => (\n  <App {...props}>\n    <HashRouter>\n      <AppRoute />\n    </HashRouter>\n  </App>\n)\n\nconst init = () => {\n  const { locale, polyglot, client, store, root } = setupApp()\n\n  createRoot(root).render(\n    <AppComponent\n      lang={locale}\n      polyglot={polyglot}\n      client={client}\n      store={store}\n    />\n  )\n}\ndocument.addEventListener('DOMContentLoaded', () => {\n  init()\n})\n"
  },
  {
    "path": "src/targets/browser/setupAppContext.js",
    "content": "import memoize from 'lodash/memoize'\n\nimport CozyClient, { DataProxyLink, StackLink } from 'cozy-client'\nimport { Document } from 'cozy-doctypes'\nimport flag from 'cozy-flags'\nimport { initTranslation } from 'twake-i18n'\n\nimport appMetadata from '@/lib/appMetadata'\nimport { schema } from '@/lib/doctypes'\nimport registerClientPlugins from '@/lib/registerClientPlugins'\nimport configureStore from '@/store/configureStore'\n\nconst setupApp = memoize(() => {\n  const root = document.querySelector('[role=application]')\n  const data = JSON.parse(root.dataset.cozy)\n\n  const protocol = window.location ? window.location.protocol : 'https:'\n  const cozyUrl = `${protocol}//${data.domain}`\n\n  const platform = {\n    isOnline: () => window?.navigator?.onLine\n  }\n\n  const links = [new StackLink({ platform })]\n  if (flag('dataproxy.queries.enabled')) {\n    // DataProxy link will be used for offline data queries\n    const dataproxyLink = new DataProxyLink()\n    links.push(dataproxyLink)\n  }\n\n  const client = new CozyClient({\n    uri: cozyUrl,\n    token: data.token,\n    appMetadata,\n    schema,\n    useCustomStore: true,\n    links\n  })\n\n  if (!Document.cozyClient) {\n    Document.registerClient(client)\n  }\n  const locale = data.locale\n  registerClientPlugins(client)\n  const polyglot = initTranslation(locale, lang => require(`@/locales/${lang}`))\n\n  const store = configureStore({\n    client,\n    t: polyglot.t.bind(polyglot)\n  })\n\n  return { locale, polyglot, client, store, root }\n})\n\nexport default setupApp\n"
  },
  {
    "path": "src/targets/browser/wdyr.js",
    "content": "import React from 'react'\n\nif (process.env.NODE_ENV === 'development') {\n  const whyDidYouRender = require('@welldone-software/why-did-you-render')\n  whyDidYouRender(React, {\n    trackAllPureComponents: true\n  })\n}\n"
  },
  {
    "path": "src/targets/intents/index.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"{{.Locale}}\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <title><%= htmlPlugin.options.title %></title>\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/assets/apple-touch-icon.png\" />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      href=\"/assets/favicon-32x32.png\"\n      sizes=\"32x32\"\n    />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      href=\"/assets/favicon-16x16.png\"\n      sizes=\"16x16\"\n    />\n    <link rel=\"mask-icon\" href=\"/assets/safari-pinned-tab.svg\" color=\"#5bbad5\" />\n    <meta name=\"theme-color\" content=\"#ffffff\" />\n    <meta name=\"color-scheme\" content=\"light dark\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"//{{.Domain}}/assets/fonts/fonts.css\">\n    {{.CozyFonts}}\n    <% htmlPlugin.files.css.forEach(function(file) { %>\n      <link rel=\"stylesheet\" href=\"<%- file %>\" />\n    <% }); %>\n    {{.ThemeCSS}}\n  </head>\n  <div\n    id=\"main\"\n    role=\"application\"\n    data-cozy=\"{{.CozyData}}\"\n  ></div>\n  <% htmlPlugin.files.js.forEach(function(file) { %>\n    <script src=\"<%- file %>\"></script>\n  <% }); %>\n</html>\n"
  },
  {
    "path": "src/targets/intents/index.jsx",
    "content": "/* eslint-disable import/order */\n\nimport 'cozy-ui/transpiled/react/stylesheet.css'\nimport 'cozy-ui/dist/cozy-ui.utils.min.css'\nimport 'cozy-viewer/dist/stylesheet.css'\nimport 'cozy-sharing/dist/stylesheet.css'\n\nimport 'whatwg-fetch'\nimport React from 'react'\nimport { getQueryParameter } from '@/lib/react-cozy-helpers'\nimport { createRoot } from 'react-dom/client'\n\nimport CozyClient from 'cozy-client'\n\nimport DriveProvider from '@/lib/DriveProvider'\nimport appMetadata from '@/lib/appMetadata'\nimport { schema } from '@/lib/doctypes'\nimport registerClientPlugins from '@/lib/registerClientPlugins'\nimport IntentHandler from '@/modules/services'\n\n// ambient styles\nimport styles from '@/styles/main.styl' // eslint-disable-line no-unused-vars\n\ndocument.addEventListener('DOMContentLoaded', () => {\n  const root = document.getElementById('main')\n  const data = JSON.parse(root.dataset.cozy)\n\n  const protocol = window.location ? window.location.protocol : 'https:'\n  const cozyUrl = `${protocol}//${data.domain}`\n\n  const { intent } = getQueryParameter()\n\n  const client = new CozyClient({\n    uri: cozyUrl,\n    token: data.token,\n    appMetadata,\n    schema\n  })\n\n  registerClientPlugins(client)\n\n  createRoot(root).render(\n    <DriveProvider\n      client={client}\n      lang={data.locale}\n      dictRequire={lang => require(`@/locales/${lang}`)}\n    >\n      <IntentHandler intentId={intent} />\n    </DriveProvider>\n  )\n})\n"
  },
  {
    "path": "src/targets/public/components/AppRouter.jsx",
    "content": "import React from 'react'\nimport { Route, Navigate } from 'react-router-dom'\n\nimport { models } from 'cozy-client'\nimport useBreakpoints from 'cozy-ui/transpiled/react/providers/Breakpoints'\n\nimport FileHistory from '@/components/FileHistory'\nimport { SentryRoutes } from '@/lib/sentry'\nimport ExternalRedirect from '@/modules/navigation/ExternalRedirect'\nimport { PublicNoteRedirect } from '@/modules/navigation/PublicNoteRedirect'\nimport LightFileViewer from '@/modules/public/LightFileViewer'\nimport PublicLayout from '@/modules/public/PublicLayout'\nimport { PublicFolderDuplicateView } from '@/modules/views/Folder/PublicFolderDuplicateView'\nimport { MovePublicFilesView } from '@/modules/views/Modal/MovePublicFilesView'\nimport OnlyOfficeView from '@/modules/views/OnlyOffice'\nimport OnlyOfficeCreateView from '@/modules/views/OnlyOffice/Create'\nimport OnlyOfficePaywallView from '@/modules/views/OnlyOffice/OnlyOfficePaywallView'\nimport { isOfficeEnabled } from '@/modules/views/OnlyOffice/helpers'\nimport { PublicFileViewer } from '@/modules/views/Public/PublicFileViewer'\nimport { PublicFolderView } from '@/modules/views/Public/PublicFolderView'\n\nconst AppRouter = ({\n  isReadOnly,\n  username,\n  isOnlyOfficeDocShared,\n  sharedDocumentId,\n  data\n}) => {\n  const { isDesktop } = useBreakpoints()\n  const isFile = data && data.type === 'file'\n\n  return (\n    <SentryRoutes>\n      <Route element={<PublicLayout />}>\n        {isOfficeEnabled(isDesktop) ? (\n          <>\n            <Route\n              path=\"onlyoffice/:fileId\"\n              element={\n                <OnlyOfficeView\n                  isPublic={true}\n                  isReadOnly={isReadOnly}\n                  username={username}\n                  isFromSharing={isOnlyOfficeDocShared}\n                  isInSharedFolder={!isFile}\n                />\n              }\n            >\n              <Route\n                path=\"paywall\"\n                element={<OnlyOfficePaywallView isPublic={true} />}\n              />\n            </Route>\n            <Route\n              path=\"onlyoffice/create/:folderId/:fileClass\"\n              element={<OnlyOfficeCreateView isPublic={true} />}\n            />\n            {models.file.shouldBeOpenedByOnlyOffice(data) && (\n              <Route\n                path=\"/\"\n                element={<Navigate to={`onlyoffice/${data.id}`} replace />}\n              />\n            )}\n          </>\n        ) : (\n          <Route path=\"onlyoffice/*\" element={<Navigate to=\"/\" />} />\n        )}\n\n        {isFile && (\n          <Route\n            path=\"/\"\n            element={<LightFileViewer files={[data]} isPublic={true} />}\n          />\n        )}\n\n        {!isFile && (\n          <>\n            <Route\n              path=\"/files/:folderId\"\n              element={<Navigate to=\"/folder/:folderId\" />}\n            />\n            <Route\n              path=\"folder\"\n              element={<PublicFolderView sharedDocumentId={sharedDocumentId} />}\n            >\n              <Route path=\"file/:fileId/revision\" element={<FileHistory />} />\n              <Route\n                path=\"paywall\"\n                element={<OnlyOfficePaywallView isPublic={true} />}\n              />\n            </Route>\n            <Route\n              path=\"folder/:folderId\"\n              element={<PublicFolderView sharedDocumentId={sharedDocumentId} />}\n            >\n              <Route path=\"file/:fileId\" element={<PublicFileViewer />} />\n              <Route path=\"file/:fileId/revision\" element={<FileHistory />} />\n              <Route\n                path=\"paywall\"\n                element={<OnlyOfficePaywallView isPublic={true} />}\n              />\n              <Route path=\"move\" element={<MovePublicFilesView />} />\n              <Route path=\"duplicate\" element={<PublicFolderDuplicateView />} />\n            </Route>\n            <Route path=\"note/:fileId\" element={<PublicNoteRedirect />} />\n            <Route path=\"external/:fileId\" element={<ExternalRedirect />} />\n            <Route\n              path=\"/*\"\n              element={<Navigate to={`folder/${sharedDocumentId}`} />}\n            />\n          </>\n        )}\n      </Route>\n    </SentryRoutes>\n  )\n}\n\nexport default AppRouter\n"
  },
  {
    "path": "src/targets/public/components/AppRouter.spec.jsx",
    "content": "// app.test.js\nimport '@testing-library/jest-dom'\nimport { render, screen } from '@testing-library/react'\nimport React from 'react'\n\nimport { createMockClient } from 'cozy-client'\n\nimport AppRouter from './AppRouter'\nimport AppLike from 'test/components/AppLike'\n\nimport { isOfficeEnabled } from '@/modules/views/OnlyOffice/helpers'\n\nconst client = createMockClient({})\n\njest.mock('modules/views/OnlyOffice/helpers', () => ({\n  ...jest.requireActual('modules/views/OnlyOffice/helpers'),\n  isOfficeEnabled: jest.fn().mockImplementation(() => true)\n}))\n\njest.mock('modules/upload/UploadQueue')\n\njest.mock('modules/views/Public/PublicFolderView', () => ({\n  PublicFolderView: jest.fn().mockImplementation(() => {\n    return <div>PublicFolderView</div>\n  })\n}))\n\njest.mock('modules/public/LightFileViewer', () => {\n  return jest.fn().mockImplementation(() => {\n    return <div>LightFileViewer</div>\n  })\n})\n\njest.mock('modules/views/OnlyOffice', () => {\n  return jest.fn().mockImplementation(() => {\n    return <div>OnlyOfficeView</div>\n  })\n})\n\ndescribe('Public AppRouter', () => {\n  const setupRouter = ({ route = '/', data = {} } = {}) => {\n    window.history.pushState({}, 'Test page', route)\n    render(\n      <AppLike client={client}>\n        <AppRouter history={history} data={data} />\n      </AppLike>\n    )\n  }\n\n  it('should display the folder view when accessing something other than a file', async () => {\n    setupRouter()\n\n    expect(screen.getByText('PublicFolderView')).toBeInTheDocument()\n  })\n\n  it('should render viewer when accessing a file', async () => {\n    setupRouter({ data: { type: 'file' } })\n\n    expect(screen.getByText('LightFileViewer')).toBeInTheDocument()\n  })\n\n  const textDocument = { name: 'document.docs', type: 'file', class: 'text' }\n\n  it('should render onlyoffice view when accessing a document file with /', async () => {\n    setupRouter({ data: textDocument })\n\n    expect(screen.getByText('OnlyOfficeView')).toBeInTheDocument()\n  })\n\n  it('should render onlyoffice view when  accessing a document file with /onlyofficeid', async () => {\n    setupRouter({ data: textDocument, route: '/onlyoffice/id' })\n\n    expect(screen.getByText('OnlyOfficeView')).toBeInTheDocument()\n  })\n\n  it('should redirect onlyoffice route to file viewer if office is disabled', async () => {\n    isOfficeEnabled.mockImplementation(() => false)\n\n    setupRouter({ data: textDocument, route: '/onlyoffice/id' })\n\n    expect(screen.getByText('LightFileViewer')).toBeInTheDocument()\n  })\n})\n"
  },
  {
    "path": "src/targets/public/index.ejs",
    "content": "<!DOCTYPE html>\n<html lang=\"{{.Locale}}\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <title><%= htmlPlugin.options.title %></title>\n    <link rel=\"apple-touch-icon\" sizes=\"180x180\" href=\"/assets/apple-touch-icon.png\" />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      href=\"/assets/favicon-32x32.png\"\n      sizes=\"32x32\"\n    />\n    <link\n      rel=\"icon\"\n      type=\"image/png\"\n      href=\"/assets/favicon-16x16.png\"\n      sizes=\"16x16\"\n    />\n    <link rel=\"mask-icon\" href=\"/assets/safari-pinned-tab.svg\" color=\"#5bbad5\" />\n    <meta name=\"theme-color\" content=\"#ffffff\" />\n    <meta name=\"color-scheme\" content=\"light dark\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <meta name=\"robots\" content=\"noindex, nofollow, noimageindex\" />\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"//{{.Domain}}/assets/fonts/fonts.css\">\n    {{.CozyFonts}}\n    <% htmlPlugin.files.css.forEach(function(file) { %>\n      <link rel=\"stylesheet\" href=\"<%- file %>\" />\n    <% }); %>\n    {{.ThemeCSS}}\n  </head>\n  <body>\n    <div\n      role=\"application\"\n      data-cozy=\"{{.CozyData}}\"\n    ></div>\n    <% htmlPlugin.files.js.forEach(function(file) { %>\n      <script src=\"<%- file %>\"></script>\n    <% }); %>\n  </body>\n</html>\n"
  },
  {
    "path": "src/targets/public/index.jsx",
    "content": "/* eslint-disable import/order */\n\n// cozy-ui css import should be done before any other import\n// otherwise the themes will not be supplied and the app crashes\nimport 'cozy-ui/transpiled/react/stylesheet.css'\nimport 'cozy-ui/dist/cozy-ui.utils.min.css'\nimport 'cozy-ui-plus/dist/stylesheet.css'\nimport 'cozy-viewer/dist/stylesheet.css'\nimport 'cozy-bar/dist/stylesheet.css'\nimport 'cozy-sharing/dist/stylesheet.css'\n\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\nimport { HashRouter } from 'react-router-dom'\nimport 'whatwg-fetch'\n\nimport CozyClient, { models } from 'cozy-client'\nimport { Document } from 'cozy-doctypes'\nimport getSharedDocument from 'cozy-sharing/dist/getSharedDocument'\nimport CozyTheme from 'cozy-ui-plus/dist/providers/CozyTheme'\nimport { I18n, initTranslation } from 'twake-i18n'\n\nimport AppRouter from './components/AppRouter'\n\nimport App from '@/components/App/App'\nimport ErrorShare from '@/components/Error/ErrorShare'\nimport appMetadata from '@/lib/appMetadata'\nimport { schema } from '@/lib/doctypes'\nimport logger from '@/lib/logger'\nimport { joinPath } from '@/lib/path'\nimport { getQueryParameter } from '@/lib/react-cozy-helpers'\nimport registerClientPlugins from '@/lib/registerClientPlugins'\nimport configureStore from '@/store/configureStore'\nimport styles from '@/styles/main.styl'\n\nimport { getPublicPageLocale } from './localeHelper'\n\nconst renderError = (lang, root) =>\n  createRoot(root).render(\n    <I18n lang={lang} dictRequire={lang => require(`@/locales/${lang}`)}>\n      <CozyTheme ignoreCozySettings className=\"u-w-100\">\n        <main className={styles['center-layout']}>\n          <ErrorShare errorType=\"public_unshared\" />\n        </main>\n      </CozyTheme>\n    </I18n>\n  )\n\nconst init = async () => {\n  const root = document.querySelector('[role=application]')\n  const dataset = JSON.parse(root.dataset.cozy)\n  const lang = getPublicPageLocale(dataset)\n  const { sharecode, isOnlyOfficeDocShared, onlyOfficeDocId, username } =\n    getQueryParameter()\n\n  const protocol = window.location ? window.location.protocol : 'https:'\n  const cozyUrl = `${protocol}//${dataset.domain}`\n\n  const client = new CozyClient({\n    uri: cozyUrl,\n    token: sharecode,\n    appMetadata,\n    schema,\n    useCustomStore: true\n  })\n  registerClientPlugins(client)\n\n  if (!Document.cozyClient) {\n    Document.registerClient(client)\n  }\n\n  const polyglot = initTranslation(lang, locale =>\n    require(`@/locales/${locale}`)\n  )\n\n  const store = configureStore({\n    client,\n    t: polyglot.t.bind(polyglot)\n  })\n\n  try {\n    const { id: sharedDocumentId, isReadOnly } = await getSharedDocument(client)\n\n    // In the case of a shared folder, we want to get the id of the only office file,\n    // not the id of the shared document (that is the folder)\n    const { data } = await client\n      .collection('io.cozy.files')\n      .get(isOnlyOfficeDocShared ? onlyOfficeDocId : sharedDocumentId)\n\n    const isNote = models.file.isNote(data)\n\n    if (isNote) {\n      window.location.href = await models.note.fetchURL(client, data, {\n        pathname: joinPath(location.pathname, '')\n      })\n    } else {\n      createRoot(root).render(\n        <App\n          isPublic\n          lang={lang}\n          polyglot={polyglot}\n          client={client}\n          store={store}\n        >\n          <HashRouter>\n            <AppRouter\n              isReadOnly={isReadOnly}\n              username={username}\n              data={data}\n              isOnlyOfficeDocShared={isOnlyOfficeDocShared}\n              sharedDocumentId={sharedDocumentId}\n            />\n          </HashRouter>\n        </App>\n      )\n    }\n  } catch (e) {\n    logger.warn(e)\n    renderError(lang, root)\n  }\n}\n\ndocument.addEventListener('DOMContentLoaded', init)\n"
  },
  {
    "path": "src/targets/public/localeHelper.js",
    "content": "import { locales } from '@/locales'\n\nconst supportedLocales = new Set(Object.keys(locales))\n\n/**\n * Returns the best matching supported locale from the browser's language\n * preferences. Iterates through `navigator.languages` (or `navigator.language`\n * as a fallback), normalizing BCP 47 tags (e.g. `zh-CN` → `zh_CN`) and\n * trying the primary subtag (e.g. `fr` from `fr-CA`) before moving on.\n *\n * @returns {string} A supported locale key (e.g. `'fr'`, `'zh_CN'`), or `'en'`\n *   if no browser language matches.\n */\nexport const getBrowserLocale = () => {\n  const languages = navigator.languages ?? [navigator.language || 'en']\n\n  for (const language of languages) {\n    // BCP 47 uses hyphens (zh-CN) but our locale keys use underscores (zh_CN)\n    const normalized = language.replaceAll('-', '_')\n    if (supportedLocales.has(normalized)) {\n      return normalized\n    }\n    const primary = normalized.split('_')[0]\n    if (supportedLocales.has(primary)) {\n      return primary\n    }\n  }\n\n  return 'en'\n}\n\n/**\n * Determines the locale for the public sharing page.\n *\n * When `isLoggedIn` is `false`, the visitor is anonymous so the browser locale\n * is used. When `isLoggedIn` is `true` or absent (cozy-stack < PR#4719), the\n * instance locale from the dataset is used for backward compatibility.\n *\n * @param {object} dataset - The parsed `data-cozy` dataset from the DOM root.\n * @param {boolean} [dataset.isLoggedIn] - Whether the current user is\n *   authenticated. Absent on older cozy-stack versions.\n * @param {string} [dataset.locale] - The Cozy instance locale (e.g. `'fr'`).\n * @returns {string} The resolved locale key to use for translations.\n */\nexport const getPublicPageLocale = dataset =>\n  'isLoggedIn' in dataset && !dataset.isLoggedIn\n    ? getBrowserLocale()\n    : dataset.locale || 'en'\n"
  },
  {
    "path": "src/targets/public/localeHelper.spec.js",
    "content": "import { getBrowserLocale, getPublicPageLocale } from './localeHelper'\n\ndescribe('getBrowserLocale', () => {\n  const originalNavigator = { ...navigator }\n\n  const mockLanguages = (languages, language) => {\n    Object.defineProperty(navigator, 'languages', {\n      value: languages,\n      configurable: true\n    })\n    Object.defineProperty(navigator, 'language', {\n      value: language ?? (languages?.[0] || 'en'),\n      configurable: true\n    })\n  }\n\n  afterEach(() => {\n    Object.defineProperty(navigator, 'languages', {\n      value: originalNavigator.languages,\n      configurable: true\n    })\n    Object.defineProperty(navigator, 'language', {\n      value: originalNavigator.language,\n      configurable: true\n    })\n  })\n\n  it('returns an exact match', () => {\n    mockLanguages(['fr'])\n    expect(getBrowserLocale()).toBe('fr')\n  })\n\n  it('normalizes BCP 47 hyphens to underscores', () => {\n    mockLanguages(['zh-CN'])\n    expect(getBrowserLocale()).toBe('zh_CN')\n  })\n\n  it('falls back to the primary subtag when the full tag is unsupported', () => {\n    mockLanguages(['fr-CA'])\n    expect(getBrowserLocale()).toBe('fr')\n  })\n\n  it('returns the first supported language from the preference list', () => {\n    mockLanguages(['sv', 'de', 'fr'])\n    expect(getBrowserLocale()).toBe('de')\n  })\n\n  it('falls back to en when no language matches', () => {\n    mockLanguages(['xx', 'yy'])\n    expect(getBrowserLocale()).toBe('en')\n  })\n\n  it('uses navigator.language when navigator.languages is undefined', () => {\n    mockLanguages(undefined, 'ja')\n    expect(getBrowserLocale()).toBe('ja')\n  })\n\n  it('falls back to en when both navigator.languages and navigator.language are undefined', () => {\n    mockLanguages(undefined, undefined)\n    expect(getBrowserLocale()).toBe('en')\n  })\n})\n\ndescribe('getPublicPageLocale', () => {\n  const originalNavigator = { ...navigator }\n\n  const mockLanguages = languages => {\n    Object.defineProperty(navigator, 'languages', {\n      value: languages,\n      configurable: true\n    })\n  }\n\n  afterEach(() => {\n    Object.defineProperty(navigator, 'languages', {\n      value: originalNavigator.languages,\n      configurable: true\n    })\n  })\n\n  it('uses browser locale when isLoggedIn is false', () => {\n    mockLanguages(['es'])\n    expect(getPublicPageLocale({ isLoggedIn: false, locale: 'fr' })).toBe('es')\n  })\n\n  it('uses instance locale when isLoggedIn is true', () => {\n    expect(getPublicPageLocale({ isLoggedIn: true, locale: 'fr' })).toBe('fr')\n  })\n\n  it('uses instance locale when isLoggedIn is absent (backward compat)', () => {\n    expect(getPublicPageLocale({ locale: 'de' })).toBe('de')\n  })\n\n  it('falls back to en when isLoggedIn is absent and locale is missing', () => {\n    expect(getPublicPageLocale({})).toBe('en')\n  })\n})\n"
  },
  {
    "path": "src/targets/services/dacc.js",
    "content": "import fetch from 'node-fetch'\n\nimport log from 'cozy-logger'\n\nimport { run } from '@/lib/dacc/dacc-run'\n\nglobal.fetch = fetch\n\nrun().catch(e => {\n  log('critical', e)\n  process.exit(1)\n})\n"
  },
  {
    "path": "src/targets/services/qualificationMigration.js",
    "content": "import fetch from 'node-fetch'\n\nimport CozyClient, { Q } from 'cozy-client'\nimport log from 'cozy-logger'\n\nimport { schema, DOCTYPE_FILES_SETTINGS } from '@/lib/doctypes'\nimport {\n  migrateQualifiedFiles,\n  extractFilesToMigrate,\n  queryFilesFromDate,\n  getMostRecentUpdatedDate\n} from '@/lib/migration/qualification'\n\nglobal.fetch = fetch\n\nconst BATCH_FILES_LIMIT = 1000 // to avoid processing too many files and get timeouts\n\n/**\n * This services migrates files qualified with the old model.\n * The up-to-date qualification model is now saved [here](https://github.com/cozy/cozy-client/blob/master/packages/cozy-client/src/assets/qualifications.json)\n * This service queries files starting from the date saved in the settings\n * and evaluate for each f ile if some old qualification attributes exist\n * to migrate to the new qualification model.\n * Note we restrict the max file processing to BATCH_FILES_LIMIT to avoid\n * service time-out.\n */\nexport const migrateQualifications = async () => {\n  log('info', 'Start qualification migration service')\n  const client = CozyClient.fromEnv(process.env, { schema })\n\n  // Get last processed file date from the settings\n  const filesSettings = await client.query(Q(DOCTYPE_FILES_SETTINGS).limitBy(1))\n  const settings =\n    filesSettings && filesSettings.data.length > 0\n      ? filesSettings.data[0]\n      : null\n  let lastProcessedFileDate = null\n  if (settings) {\n    lastProcessedFileDate = settings.lastProcessedFileDate\n  }\n  // Get a batch of sorted files starting from the date\n  const res = await queryFilesFromDate(\n    client,\n    lastProcessedFileDate,\n    BATCH_FILES_LIMIT\n  )\n  const filesByDate = res.data\n  if (filesByDate.length < 1) {\n    log('warn', 'No new file to process')\n    return\n  }\n  lastProcessedFileDate =\n    filesByDate[filesByDate.length - 1].cozyMetadata.updatedAt\n\n  // Filter the files with old qualification attributes\n  const filesToMigrate = extractFilesToMigrate(filesByDate)\n\n  if (filesToMigrate.length < 1) {\n    log('info', 'No file found with old qualification')\n  } else {\n    const migratedFiles = await migrateQualifiedFiles(client, filesToMigrate)\n    log(\n      'info',\n      `Migrated ${migratedFiles.length} files with old qualifications`\n    )\n    lastProcessedFileDate =\n      getMostRecentUpdatedDate(migratedFiles) || lastProcessedFileDate\n  }\n\n  // Save the last processed file date in the settings\n  log('info', `Save last processed file date: ${lastProcessedFileDate}`)\n  if (settings) {\n    await client.save({ ...settings, lastProcessedFileDate })\n  } else {\n    await client.create(DOCTYPE_FILES_SETTINGS, { lastProcessedFileDate })\n  }\n}\n\nmigrateQualifications().catch(e => {\n  log('critical', e)\n  process.exit(1)\n})\n"
  },
  {
    "path": "test/__mocks__/fileMock.js",
    "content": "'use strict'\n\nmodule.exports = 'test-file-stub'\n"
  },
  {
    "path": "test/__mocks__/mockedRouter.js",
    "content": "export const mockedRouter = {\n  createHref: jest.fn(),\n  push: jest.fn(),\n  replace: jest.fn(),\n  go: jest.fn(),\n  goBack: jest.fn(),\n  goForward: jest.fn(),\n  setRouteLeaveHook: jest.fn(),\n  isActive: jest.fn(),\n  location: {\n    pathname: ''\n  }\n}\n"
  },
  {
    "path": "test/components/AppLike.jsx",
    "content": "import React from 'react'\nimport { DndProvider } from 'react-dnd'\nimport { HTML5Backend } from 'react-dnd-html5-backend'\nimport { Provider } from 'react-redux'\nimport { HashRouter } from 'react-router-dom'\nimport { createStore } from 'redux'\n\nimport { CozyProvider } from 'cozy-client'\nimport { SharingContext, NativeFileSharingProvider } from 'cozy-sharing'\nimport { Layout } from 'cozy-ui/transpiled/react/Layout'\nimport AlertProvider from 'cozy-ui/transpiled/react/providers/Alert'\nimport { BreakpointsProvider } from 'cozy-ui/transpiled/react/providers/Breakpoints'\nimport CozyTheme from 'cozy-ui-plus/dist/providers/CozyTheme'\nimport { I18n } from 'twake-i18n'\n\nimport PushBannerProvider from '@/components/PushBanner/PushBannerProvider'\nimport RightClickProvider from '@/components/RightClick/RightClickProvider'\nimport ClipboardProvider from '@/contexts/ClipboardProvider'\nimport { AcceptingSharingProvider } from '@/lib/AcceptingSharingContext'\nimport FabProvider from '@/lib/FabProvider'\nimport { ModalContext } from '@/lib/ModalContext'\nimport { ViewSwitcherContextProvider } from '@/lib/ViewSwitcherContext'\nimport enLocale from '@/locales/en.json'\nimport { SelectionProvider } from '@/modules/selection/SelectionProvider'\nimport { NewItemHighlightProvider } from '@/modules/upload/NewItemHighlightProvider'\n\nconst mockStore = createStore(() => ({\n  mobile: {\n    url: 'cozy-url://'\n  }\n}))\n\nexport const TestI18n = ({ children }) => {\n  return (\n    <I18n lang=\"en\" dictRequire={() => enLocale}>\n      {children}\n    </I18n>\n  )\n}\n\nconst mockSharingContextValue = {\n  refresh: jest.fn(),\n  hasWriteAccess: jest.fn(),\n  getRecipients: jest.fn().mockReturnValue([]),\n  getDocumentPermissions: jest.fn(),\n  isOwner: jest.fn(),\n  allLoaded: jest.fn(),\n  hasSharedParent: jest.fn(),\n  getSharingLink: jest.fn()\n}\n\nconst mockModalContextValue = {\n  pushModal: jest.fn(),\n  modalStack: []\n}\n\nconst AppLike = ({\n  children,\n  store,\n  client,\n  sharingContextValue,\n  modalContextValue\n}) => (\n  <CozyTheme>\n    <Provider store={store || (client && client.store) || mockStore}>\n      <CozyProvider client={client}>\n        <TestI18n>\n          <SharingContext.Provider\n            value={sharingContextValue || mockSharingContextValue}\n          >\n            <AcceptingSharingProvider>\n              <NativeFileSharingProvider>\n                <HashRouter>\n                  <NewItemHighlightProvider>\n                    <SelectionProvider>\n                      <ViewSwitcherContextProvider>\n                        <BreakpointsProvider>\n                          <AlertProvider>\n                            <PushBannerProvider>\n                              <ClipboardProvider>\n                                <ModalContext.Provider\n                                  value={\n                                    modalContextValue || mockModalContextValue\n                                  }\n                                >\n                                  <DndProvider backend={HTML5Backend}>\n                                    <FabProvider>\n                                      <RightClickProvider>\n                                        <Layout>{children}</Layout>\n                                      </RightClickProvider>\n                                    </FabProvider>\n                                  </DndProvider>\n                                </ModalContext.Provider>\n                              </ClipboardProvider>\n                            </PushBannerProvider>\n                          </AlertProvider>\n                        </BreakpointsProvider>\n                      </ViewSwitcherContextProvider>\n                    </SelectionProvider>\n                  </NewItemHighlightProvider>\n                </HashRouter>\n              </NativeFileSharingProvider>\n            </AcceptingSharingProvider>\n          </SharingContext.Provider>\n        </TestI18n>\n      </CozyProvider>\n    </Provider>\n  </CozyTheme>\n)\n\nexport default AppLike\n"
  },
  {
    "path": "test/components/FolderContent.jsx",
    "content": "import React, { useMemo } from 'react'\n\nimport { useQuery } from 'cozy-client'\n\nimport { buildDriveQuery } from '@/queries'\n\n/** A simple component firing the same queries as DriveView */\nconst Component = ({ folderId, sortOrder }) => {\n  const fileQuery = useMemo(\n    () =>\n      buildDriveQuery({\n        currentFolderId: folderId,\n        type: 'file',\n        sortAttribute: sortOrder.attribute,\n        sortOrder: sortOrder.order\n      }),\n    [folderId, sortOrder]\n  )\n  const folderQuery = useMemo(\n    () =>\n      buildDriveQuery({\n        currentFolderId: folderId,\n        type: 'directory',\n        sortAttribute: sortOrder.attribute,\n        sortOrder: sortOrder.order\n      }),\n    [folderId, sortOrder]\n  )\n  const { data: files } = useQuery(fileQuery.definition, fileQuery.options)\n\n  const { data: folders } = useQuery(\n    folderQuery.definition,\n    folderQuery.options\n  )\n  return (\n    <div>\n      {folders && folders.length} -{files && files.length}\n    </div>\n  )\n}\n\nexport default Component\n"
  },
  {
    "path": "test/components/FolderContent.spec.jsx",
    "content": "import { setupFolderContent } from 'test/setup'\n\ndescribe('FolderContent', () => {\n  /**\n   * It is important to check that the state is correctly filled since\n   * other components will rely on the store's content\n   */\n  it('should fill the state', async () => {\n    const { root, client } = await setupFolderContent()\n\n    expect(client.requestQuery).toHaveBeenCalled()\n    expect(root.text()).toBe('10')\n\n    const state = client.store.getState()\n    expect(state.cozy.queries).toEqual(\n      expect.objectContaining({\n        'directory folderid123456 name desc': expect.objectContaining({\n          data: expect.any(Array)\n        })\n      })\n    )\n  })\n})\n"
  },
  {
    "path": "test/components/__snapshots__/File.spec.js.snap",
    "content": "exports[`File component should render a folder correctly 1`] = `null`;\n"
  },
  {
    "path": "test/data.js",
    "content": "export const folder = {\n  id: 'cc0a6981d593205dcefe29dcb2021b80',\n  _id: 'cc0a6981d593205dcefe29dcb2021b80',\n  _type: 'io.cozy.files',\n  type: 'directory',\n  attributes: {\n    type: 'directory',\n    name: 'Folder Name',\n    dir_id: 'io.cozy.files.root-dir',\n    created_at: '2020-11-03T12:31:43.027718+01:00',\n    updated_at: '2020-11-03T12:31:43.027718+01:00',\n    tags: [],\n    path: '/Folder Name',\n    cozyMetadata: {\n      doctypeVersion: '1',\n      metadataVersion: 1,\n      createdAt: '2020-11-03T12:31:43.028779+01:00',\n      createdByApp: 'drive',\n      updatedAt: '2020-11-03T12:31:43.028779+01:00',\n      updatedByApps: [\n        {\n          slug: 'drive',\n          date: '2020-11-03T12:31:43.028779+01:00',\n          instance: 'http://cozy.tools:8080/'\n        }\n      ],\n      createdOn: 'http://cozy.tools:8080/'\n    }\n  },\n  meta: {\n    rev: '1-99e787c29e88dc5a8a2a7aafc4f682ed'\n  },\n  links: {\n    self: '/files/cc0a6981d593205dcefe29dcb2021b80'\n  },\n  name: 'Folder Name',\n  dir_id: 'io.cozy.files.root-dir',\n  created_at: '2020-11-03T12:31:43.027718+01:00',\n  updated_at: '2020-11-03T12:31:43.027718+01:00',\n  tags: [],\n  path: '/Folder Name',\n  cozyMetadata: {\n    doctypeVersion: '1',\n    metadataVersion: 1,\n    createdAt: '2020-11-03T12:31:43.028779+01:00',\n    createdByApp: 'drive',\n    updatedAt: '2020-11-03T12:31:43.028779+01:00',\n    updatedByApps: [\n      {\n        slug: 'drive',\n        date: '2020-11-03T12:31:43.028779+01:00',\n        instance: 'http://cozy.tools:8080/'\n      }\n    ],\n    createdOn: 'http://cozy.tools:8080/'\n  },\n  old_versions: {\n    target: {\n      id: 'cc0a6981d593205dcefe29dcb2021b80',\n      _id: 'cc0a6981d593205dcefe29dcb2021b80',\n      _type: 'io.cozy.files',\n      type: 'directory',\n      attributes: {\n        type: 'directory',\n        name: 'Folder Name',\n        dir_id: 'io.cozy.files.root-dir',\n        created_at: '2020-11-03T12:31:43.027718+01:00',\n        updated_at: '2020-11-03T12:31:43.027718+01:00',\n        tags: [],\n        path: '/Folder Name',\n        cozyMetadata: {\n          doctypeVersion: '1',\n          metadataVersion: 1,\n          createdAt: '2020-11-03T12:31:43.028779+01:00',\n          createdByApp: 'drive',\n          updatedAt: '2020-11-03T12:31:43.028779+01:00',\n          updatedByApps: [\n            {\n              slug: 'drive',\n              date: '2020-11-03T12:31:43.028779+01:00',\n              instance: 'http://cozy.tools:8080/'\n            }\n          ],\n          createdOn: 'http://cozy.tools:8080/'\n        }\n      },\n      meta: {\n        rev: '1-99e787c29e88dc5a8a2a7aafc4f682ed'\n      },\n      links: {\n        self: '/files/cc0a6981d593205dcefe29dcb2021b80'\n      },\n      name: 'Folder Name',\n      dir_id: 'io.cozy.files.root-dir',\n      created_at: '2020-11-03T12:31:43.027718+01:00',\n      updated_at: '2020-11-03T12:31:43.027718+01:00',\n      tags: [],\n      path: '/Folder Name',\n      cozyMetadata: {\n        doctypeVersion: '1',\n        metadataVersion: 1,\n        createdAt: '2020-11-03T12:31:43.028779+01:00',\n        createdByApp: 'drive',\n        updatedAt: '2020-11-03T12:31:43.028779+01:00',\n        updatedByApps: [\n          {\n            slug: 'drive',\n            date: '2020-11-03T12:31:43.028779+01:00',\n            instance: 'http://cozy.tools:8080/'\n          }\n        ],\n        createdOn: 'http://cozy.tools:8080/'\n      }\n    },\n    name: 'old_versions',\n    doctype: 'io.cozy.files.versions'\n  }\n}\n\nexport const actionsMenu = [\n  {\n    item: {\n      icon: 'item',\n      Component: jest.fn().mockReturnValue('ActionsMenuItem'),\n      action: jest.fn()\n    }\n  }\n]\n\nexport const officeDoc = {\n  type: 'io.cozy.office.url',\n  id: '32e07d806f9b0139c541543d7eb8149c',\n  class: 'text',\n  name: 'Letter.docx',\n  path: '/path',\n  attributes: {\n    document_id: '32e07d806f9b0139c541543d7eb8149c',\n    subdomain: 'flat',\n    protocol: 'https',\n    instance: 'bob.cozy.example',\n    public_name: 'Bob',\n    onlyoffice: {\n      url: 'https://documentserver/',\n      documentType: 'word',\n      document: {\n        filetype: 'docx',\n        key: '32e07d806f9b0139c541543d7eb8149c-56a653128a91a5c2291db9735b43fd86',\n        title: 'Letter.docx',\n        url: 'https://bob.cozy.example/files/downloads/735e6cf69af2db82/Letter.docx?Dl=1',\n        info: {\n          owner: 'Bob',\n          uploaded: '2010-07-07 3:46 PM'\n        }\n      },\n      editor: {\n        callbackUrl:\n          'https://bob.cozy.example/office/32e07d806f9b0139c541543d7eb8149c/callback',\n        lang: 'en',\n        mode: 'edit'\n      }\n    }\n  }\n}\n\nexport const officeDocParam = {\n  data: officeDoc\n}\n"
  },
  {
    "path": "test/dummies/dummyBreadcrumbPath.js",
    "content": "import { ROOT_DIR_ID, SHARED_DRIVES_DIR_ID } from '@/constants/config'\n\nconst dummyBreadcrumbPathSmall = (parentId, parentName) => [\n  { id: parentId, name: parentName },\n  { id: 'parentFolderId', name: 'parent' },\n  { id: 'currentFolderId', name: 'current' }\n]\n\nconst dummyBreadcrumbPathLarge = (parentId, parentName) => [\n  { id: parentId, name: parentName },\n  { id: 'grandParentFolderId', name: 'grandParent' },\n  { id: 'parentFolderId', name: 'parent' },\n  { id: 'currentFolderId', name: 'current' }\n]\n\nexport const dummyBreadcrumbPathNoRootSmall = () =>\n  dummyBreadcrumbPathSmall('mainfolder', 'Some Main Folder')\nexport const dummyBreadcrumbPathNoRootLarge = () =>\n  dummyBreadcrumbPathLarge('mainfolder', 'Some Main Folder')\n\nexport const dummyBreadcrumbPathWithRootSmall = () =>\n  dummyBreadcrumbPathSmall(ROOT_DIR_ID, 'Drive')\nexport const dummyBreadcrumbPathWithRootLarge = () =>\n  dummyBreadcrumbPathLarge(ROOT_DIR_ID, 'Drive')\n\nexport const dummyBreadcrumbPathWithSharedDriveSmall = () =>\n  dummyBreadcrumbPathSmall(SHARED_DRIVES_DIR_ID, 'Shared Drive')\nexport const dummyBreadcrumbPathWithSharedDriveLarge = () =>\n  dummyBreadcrumbPathLarge(SHARED_DRIVES_DIR_ID, 'Shared Drive')\n\nexport const dummyRootBreadcrumbPath = () => ({\n  id: ROOT_DIR_ID,\n  name: 'Drive'\n})\n"
  },
  {
    "path": "test/dummies/dummyFile.js",
    "content": "// eslint-disable-next-line no-unused-vars\nconst { IOCozyFile } = require('cozy-client/dist/types')\n\n/**\n * Create a dummy file, with overridden value of given param\n *\n * @param {Partial<IOCozyFile>} [file={}] - optional file with value to keep\n * @returns {IOCozyFile} a dummy file\n */\nexport const dummyFile = file => ({\n  _id: 'id-file',\n  _type: 'doctype-file',\n  name: 'name',\n  id: 'id-file',\n  icon: 'icon',\n  path: '/path',\n  type: 'directory',\n  ...file\n})\n\n/**\n * Create a dummy note, with overridden value of given param\n *\n * @param {Partial<IOCozyFile>} [note={}]\n * @returns {IOCozyFile} a dummy note\n */\nexport const dummyNote = note => ({\n  ...dummyFile(),\n  type: 'file',\n  metadata: {\n    content: '',\n    schema: '',\n    title: '',\n    version: ''\n  },\n  ...note\n})\n"
  },
  {
    "path": "test/generate.js",
    "content": "export const generateFile = ({\n  i,\n  prefix = 'foobar',\n  type = 'file',\n  ext,\n  path = '/',\n  dir_id = 'io.cozy.files.root-dir',\n  updated_at = '',\n  encrypted = false\n} = {}) => {\n  let extension = ext\n  if (extension === undefined) {\n    if (type === 'file') {\n      extension = '.pdf'\n    } else if (type === 'directory') {\n      extension = ''\n    }\n  }\n  let optional = {}\n  if (type === 'file') {\n    optional = {\n      ...optional,\n      size: 10\n    }\n  }\n  if (updated_at !== '') {\n    optional = {\n      ...optional,\n      updated_at\n    }\n  }\n  return {\n    dir_id,\n    displayedPath: path,\n    id: `${type}-${prefix}${i}`,\n    _id: `${type}-${prefix}${i}`,\n    name: `${prefix}${i}${extension}`,\n    path: `${path === '/' ? '' : path}/${prefix}${i}${extension}`,\n    type,\n    _type: 'io.cozy.files',\n    encrypted,\n    ...optional\n  }\n}\n"
  },
  {
    "path": "test/helpers/index.js",
    "content": "import configureMockStore from 'redux-mock-store'\nimport thunk from 'redux-thunk'\n\nconst middlewares = [thunk]\nexport const mockStore = configureMockStore(middlewares)\n"
  },
  {
    "path": "test/jestLib/json-transformer.js",
    "content": "module.exports = {\n  process: src => ({\n    code: `module.exports = ${src};`\n  })\n}\n"
  },
  {
    "path": "test/setup.jsx",
    "content": "/**\n * Setup utilities to be used in tests\n */\n\nimport { configure, render, act } from '@testing-library/react'\nimport React from 'react'\n\nimport CozyClient from 'cozy-client'\n\nimport { generateFile } from './generate'\nimport configureStore from '../src/store/configureStore'\nimport AppLike from 'test/components/AppLike'\nimport FolderContent from 'test/components/FolderContent'\n\njest.mock('cozy-keys-lib', () => ({\n  withVaultClient: BaseComponent => {\n    const Component = props => (\n      <>\n        {({ vaultClient }) => (\n          <BaseComponent vaultClient={vaultClient} {...props} />\n        )}\n      </>\n    )\n\n    Component.displayName = `withVaultClient(${\n      BaseComponent.displayName || BaseComponent.name\n    })`\n\n    return Component\n  },\n  useVaultClient: jest.fn()\n}))\n\nconfigure({ testIdAttribute: 'data-testid' })\n\nexport const mockCozyClientRequestQuery = dir_id => {\n  beforeEach(() => {\n    const files = Array(10)\n      .fill(null)\n      .map((x, i) => generateFile({ i, dir_id }))\n    const directories = Array(3)\n      .fill(null)\n      .map((x, i) => generateFile({ i, type: 'directory', dir_id }))\n    jest\n      .spyOn(CozyClient.prototype, 'requestQuery')\n      .mockImplementation(queryDefinition => {\n        if (queryDefinition.selector?.type === 'directory') {\n          return Promise.resolve({\n            data: directories\n          })\n        } else {\n          return Promise.resolve({\n            data: files\n          })\n        }\n      })\n  })\n\n  afterEach(() => {\n    CozyClient.prototype.requestQuery.mockRestore()\n  })\n}\n\nexport const setupStore = ({\n  client,\n  initialStoreState,\n  setStoreToClient = true\n} = {}) => {\n  return configureStore({\n    client,\n    t: x => x,\n    initialState: initialStoreState,\n    logger: false,\n    setStoreToClient\n  })\n}\n\nexport const setupStoreAndClient = ({ initialStoreState } = {}) => {\n  const client = new CozyClient({\n    useCustomStore: true\n  })\n  client.getStackClient().setUri('http://test.cloud')\n\n  const store = setupStore({ client, initialStoreState })\n  return { store, client }\n}\n\n/**\n * Helper function for tests\n *\n * - Mounts a FolderContent view with a client and the store of the app\n * - CozyClient::requestQuery needs to be mocked\n * - After mount, the store should have content in .cozy.queries\n */\nconst setupFolderContent = async ({ folderId, initialStoreState }) => {\n  const { client, store } = setupStoreAndClient({\n    initialStoreState\n  })\n\n  const sortOrder = {\n    attribute: 'name',\n    order: 'desc'\n  }\n  let root\n\n  await act(async () => {\n    root = render(\n      <AppLike store={store} client={client}>\n        <FolderContent folderId={folderId} sortOrder={sortOrder} />\n      </AppLike>\n    )\n  })\n\n  return { root, store, client }\n}\n\nexport { setupFolderContent }\n"
  },
  {
    "path": "transifex.yml",
    "content": "git:\n filters:\n  - filter_type: file\n    file_format: KEYVALUEJSON\n    source_language: en\n    source_file: 'src/locales/en.json'\n    translation_files_expression: 'src/locales/<lang>.json'\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"extends\": \"cozy-tsconfig\",\n  \"exclude\": [\"node_modules\", \"build\"],\n  \"compilerOptions\": {\n    \"emitDeclarationOnly\": false,\n    \"noEmit\": true,\n    \"paths\": {\n      \"@/*\": [\"./src/*\"]\n    }\n  }\n}\n"
  }
]