From 45816e4e03cdaade2accd2577a84f9dc62f902c5 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 26 Oct 2026 02:21:17 +0100 Subject: [PATCH 1/4] Revert "upd(core): prepare 3.6 development branch and require PHP 8.4" This reverts commit b8cfb08f2cdf039a7d1632df27e8918c398da23f. --- .github/docker/ci/Dockerfile | 2 +- .github/docker/ci/README.md | 4 ++-- .github/docker/ci/docker-compose.yml | 4 ++-- .github/workflows/build.yml | 14 +++++++------- .github/workflows/ci.yml | 8 ++++---- .github/workflows/upgrade.yml | 4 ++-- README.md | 26 +++++++++++++------------- composer.json | 2 +- core/composer.json | 4 ++-- core/factory/version.php | 4 ++-- publiccode.yml | 8 ++++---- 11 files changed, 40 insertions(+), 40 deletions(-) diff --git a/.github/docker/ci/Dockerfile b/.github/docker/ci/Dockerfile index 18a95066b6..0fab93e61a 100644 --- a/.github/docker/ci/Dockerfile +++ b/.github/docker/ci/Dockerfile @@ -6,7 +6,7 @@ # installer, the migrations and the seeded data, and the built in web server is # enough to prove the installed site answers. Which web server serves it is what # the Salo images in .github/docker/Dockerfile are for. -ARG PHP_VERSION=8.4 +ARG PHP_VERSION=8.3 FROM php:${PHP_VERSION}-cli # libpq/libzip/libicu are the build inputs of the extensions below; the -dev diff --git a/.github/docker/ci/README.md b/.github/docker/ci/README.md index f96ece53d0..4aad2c0d19 100644 --- a/.github/docker/ci/README.md +++ b/.github/docker/ci/README.md @@ -34,7 +34,7 @@ EVO_DB_TYPE=sqlite EVO_APP_DIR="$PWD" .github/docker/ci/install-and-smoke.sh ``` `PHP_VERSION`, `MYSQL_IMAGE_TAG`, `POSTGRES_IMAGE_TAG`, `MYSQL_PORT` and -`POSTGRES_PORT` override the defaults (PHP 8.4, MySQL 9.7 LTS, PostgreSQL 18.6 +`POSTGRES_PORT` override the defaults (PHP 8.3, MySQL 9.7 LTS, PostgreSQL 18.6 LTS, the servers' own ports). ## Upgrading @@ -79,7 +79,7 @@ no PHP on it, and what pins the PHP version when a version question is the one being investigated. One PHP version, every database: what the job proves is the installer's database -paths, which do not vary with the PHP minor. PHP 8.4 and 8.5 are covered by +paths, which do not vary with the PHP minor. PHP 8.3 through 8.5 are covered by the analysis and unit test jobs. The sqlite leg starts no container. The same workflow also builds this image from the official `php:8.6-rc-cli` diff --git a/.github/docker/ci/docker-compose.yml b/.github/docker/ci/docker-compose.yml index 7f160caa0d..4af612dca3 100644 --- a/.github/docker/ci/docker-compose.yml +++ b/.github/docker/ci/docker-compose.yml @@ -57,7 +57,7 @@ services: context: ../../.. dockerfile: .github/docker/ci/Dockerfile args: - PHP_VERSION: ${PHP_VERSION:-8.4} + PHP_VERSION: ${PHP_VERSION:-8.3} depends_on: mysql: condition: service_healthy @@ -75,7 +75,7 @@ services: context: ../../.. dockerfile: .github/docker/ci/Dockerfile args: - PHP_VERSION: ${PHP_VERSION:-8.4} + PHP_VERSION: ${PHP_VERSION:-8.3} depends_on: pgsql: condition: service_healthy diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d3abf7e91d..b2a3cf56d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,8 +58,8 @@ jobs: uses: shivammathur/setup-php@v2 with: # Built on the lowest supported version so the vendor tree stays - # usable on 8.4 and 8.5 alike. - php-version: '8.4' + # usable on 8.3 and 8.4 alike. + php-version: '8.3' extensions: ctype, dom, fileinfo, filter, iconv, intl, json, mbstring, openssl, pdo, pdo_sqlite, readline, simplexml, sqlite3, tokenizer, xml, xmlreader, zip coverage: none tools: composer:v2 @@ -72,8 +72,8 @@ jobs: uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} - key: composer-${{ runner.os }}-php8.4-build-${{ hashFiles('core/composer.lock') }} - restore-keys: composer-${{ runner.os }}-php8.4-build- + key: composer-${{ runner.os }}-php8.3-build-${{ hashFiles('core/composer.lock') }} + restore-keys: composer-${{ runner.os }}-php8.3-build- - name: Install core dependencies working-directory: core @@ -183,9 +183,9 @@ jobs: # the ones that did build stay usable. fail-fast: false matrix: - # 8.4 is the lowest supported version and the one the vendor tree in the + # 8.3 is the lowest supported version and the one the vendor tree in the # zip is built on, so it proves the archive is portable upwards. - php: ['8.4', '8.5'] + php: ['8.3', '8.4', '8.5'] # Three different PHP SAPIs, not three front ends over one PHP: apache # runs mod_php, nginx talks FastCGI to php-fpm, and FrankenPHP embeds # PHP inside Caddy. They cannot share a base image, so each is a build. @@ -196,7 +196,7 @@ jobs: PUSH_IMAGE: ${{ github.event_name != 'workflow_dispatch' || inputs.docker == 'push' }} PHP_VERSION: ${{ matrix.php }} SERVER: ${{ matrix.server }} - # The combination that carries the unsuffixed tags, so `:3.6.x` keeps + # The combination that carries the unsuffixed tags, so `:3.5.x` keeps # meaning what it meant before the matrix existed. DEFAULT_PHP: '8.4' DEFAULT_SERVER: apache diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5af9ca48c..5d04b1fd95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.4', '8.5'] + php: ['8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v7 @@ -61,7 +61,7 @@ jobs: strategy: fail-fast: false matrix: - php: ['8.4', '8.5'] + php: ['8.3', '8.4', '8.5'] steps: - uses: actions/checkout@v7 @@ -184,7 +184,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: '8.3' extensions: ${{ env.PHP_EXTENSIONS }}, pdo_mysql, pdo_pgsql, sqlite3 ini-values: error_reporting=E_ALL, display_errors=On, display_startup_errors=On, log_errors=On, error_log=/dev/stderr coverage: none @@ -224,7 +224,7 @@ jobs: with: # The audit reads composer.lock and the advisory database; it does not run the code, # so one version is enough. Kept at the lowest supported one for consistency. - php-version: '8.4' + php-version: '8.3' extensions: ${{ env.PHP_EXTENSIONS }} coverage: none tools: composer:v2 diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml index fa8e303556..8924575561 100644 --- a/.github/workflows/upgrade.yml +++ b/.github/workflows/upgrade.yml @@ -110,12 +110,12 @@ jobs: if: matrix.leg.db != 'sqlite' run: docker compose -f "new/$COMPOSE" --profile ${{ matrix.leg.db }} up -d --wait ${{ matrix.leg.db }} - # 8.4 is the lowest version both trees support, so it is the one that can + # 8.3 is the lowest version both trees support, so it is the one that can # run the old installer and the new updater in the same job. - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: '8.3' extensions: ctype, dom, fileinfo, filter, iconv, intl, json, mbstring, openssl, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, readline, simplexml, sqlite3, tokenizer, xml, xmlreader, zip ini-values: error_reporting=E_ALL, display_errors=On, display_startup_errors=On, log_errors=On, error_log=/dev/stderr coverage: none diff --git a/README.md b/README.md index 8732c7d704..752ccadbf8 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![CMS Evolution](https://img.shields.io/badge/CMS-Evolution-brightgreen.svg)](https://packagist.org/packages/evolution-cms/evolution) [![GitHub release](https://img.shields.io/github/release/evolution-cms/evolution.svg)](https://github.com/evolution-cms/evolution/releases) -[![PHP supported](https://img.shields.io/badge/PHP_supported-8.4_%7C_8.5-777BB4?logo=php&logoColor=white)](#php-compatibility) +[![PHP supported](https://img.shields.io/badge/PHP_supported-8.3_%7C_8.4_%7C_8.5-777BB4?logo=php&logoColor=white)](#php-compatibility) [![PHP recommended](https://img.shields.io/badge/PHP_recommended-8.4-2ea44f?logo=php&logoColor=white)](#php-compatibility) [![Issues](https://img.shields.io/github/issues-closed-raw/evolution-cms/evolution.svg?maxAge=2592000)](https://github.com/evolution-cms/evolution/issues) [![Total Installs](https://img.shields.io/packagist/dt/evolution-cms/evolution)](https://packagist.org/packages/evolution-cms/evolution) @@ -53,21 +53,21 @@ You can use the single click installer: [Evolution CMS Installer](https://github ### PHP compatibility -Evolution CMS **3.6.x** requires **PHP 8.4 or later within PHP 8.x** (`^8.4` in [core/composer.json](core/composer.json)). +Evolution CMS **3.5.x** requires **PHP 8.3 or later within PHP 8.x** (`^8.3` in [core/composer.json](core/composer.json)). | PHP version | Evolution CMS support | Recommendation | | --- | --- | --- | -| **8.4** | Supported; minimum required version | **Recommended for production and new installations** | +| **8.3** | Supported; minimum required version | Suitable for existing installations | +| **8.4** | Supported | **Recommended for production and new installations** | | **8.5** | Supported | Check installed extras and custom code before upgrading | -| **8.6 RC** | Experimental compatibility checks only | Not recommended for production | -The [CI workflow](.github/workflows/ci.yml) runs static analysis and unit tests on PHP **8.4 and 8.5**. PHP **8.6 RC** has a separate experimental compatibility job and is not recommended for production. +The [CI workflow](.github/workflows/ci.yml) runs unit tests on PHP **8.3, 8.4 and 8.5**. PHP **8.6 RC** has a separate experimental compatibility job and is not recommended for production. Use the latest patch release of your chosen PHP version. See the [PHP support lifecycle](https://www.php.net/supported-versions.php) for maintenance and security support dates. ## Update and switch versions -A site can move between a release (such as `3.5.9`), a development branch (such as `3.6.x`) and a fork in either direction. The target version must support the server's PHP version. Take a database backup first: a switch back to an older release keeps the database schema of the newer one. +A site can move between a release (such as `3.5.8`), the `3.5.x` development branch (nightly) and a fork in either direction. Take a database backup first: a switch back to an older release keeps the database schema of the newer one. ### With Artisan (recommended) @@ -75,10 +75,10 @@ Run from the `core/` directory: | Command | What it does | | --- | --- | -| `php artisan make:site` | Updates along the line the site runs. A release moves to the newest release of its major version, a site on `3.6.x` takes the latest `3.6.x` again (run it every night to follow the nightly), a site on a fork branch takes that branch again. It never moves a site back to an older release. | -| `php artisan make:site update 3.6.x` | Switches to the `3.6.x` branch. The site stays on it until a version is passed again. | -| `php artisan make:site update 3.5.9` | Switches to a release, also an older one (back from `3.6.x` or from a fork). | -| `php artisan make:site update nightly-3.6.x` | Switches to the nightly build tag. | +| `php artisan make:site` | Updates along the line the site runs. A release moves to the newest release of its major version, a site on `3.5.x` takes the latest `3.5.x` again (run it every night to follow the nightly), a site on a fork branch takes that branch again. It never moves a site back to an older release. | +| `php artisan make:site update 3.5.x` | Switches to the `3.5.x` branch. The site stays on it until a version is passed again. | +| `php artisan make:site update 3.5.8` | Switches to a release, also an older one (back from `3.5.x` or from a fork). | +| `php artisan make:site update nightly-3.5.x` | Switches to the nightly build tag. | | `php artisan make:site update https://github.com//evolution/tree/` | Switches to a branch or tag of a fork, as GitHub shows it in the browser. `.../releases/tag/` and `.../commit/` work too. A plain `make:site` then stays on that branch of the fork. | | `php artisan make:site update --repository=/evolution` | The same as the URL form. | @@ -98,7 +98,7 @@ Packages added through `core/custom/composer.json` are kept on every update and Get the files of the version you want, lay them over the site and run the installer in update mode: ```bash -composer create-project --no-install evolution-cms/evolution /tmp/evo 3.6.x-dev # or 3.5.9 +composer create-project --no-install evolution-cms/evolution /tmp/evo 3.5.x-dev # or 3.5.8 cp -a /tmp/evo/. /path/to/site/ # or unpack a release / branch archive there cd /path/to/site/install php cli-install.php --typeInstall=2 --removeInstall=y @@ -110,11 +110,11 @@ Instead of the last two lines you can open `/install/` in the browser while logg ### From the manager -The update widget on the dashboard follows the line the site runs. A release is offered newer releases. A site updated from `3.6.x`, `nightly-3.6.x` or a fork branch is offered that branch again once it has new commits (with a link to the changes), from the repository it came from. **Manual update (WEB)** queues `make:site` for that target, so everything above applies. To leave a branch for a release, run `php artisan make:site update `. +The update widget on the dashboard follows the line the site runs. A release is offered newer releases. A site updated from `3.5.x`, `nightly-3.5.x` or a fork branch is offered that branch again once it has new commits (with a link to the changes), from the repository it came from. **Manual update (WEB)** queues `make:site` for that target, so everything above applies. To leave a branch for a release, run `php artisan make:site update `. ### Things to know -- When moving from **3.5.8** to `3.6.x`, the 3.5.8 updater reports the old version at the end; the site is on `3.6.x` all the same. The 3.5.8 CLI installer cannot restore custom Composer packages, so switch back to 3.5.8 with `make:site update 3.5.8` rather than with its `cli-install.php`. +- Moving from **3.5.8** to `3.5.x` is done by the 3.5.8 updater, which reports the old version at the end; the site is on `3.5.x` all the same. Its CLI installer cannot restore custom Composer packages, so switch back to 3.5.8 with `make:site update 3.5.8` rather than with its `cli-install.php`. - After a git pull or rsync of new code, with no update step, run `php artisan migrate` and `composer update ` in `core/`. Compiled templates refresh by themselves. - An old fork branch is an old version of Evolution CMS: switching to it is a downgrade. diff --git a/composer.json b/composer.json index 343d31163f..9657c40b05 100644 --- a/composer.json +++ b/composer.json @@ -36,7 +36,7 @@ "vendor-dir": "vendor" }, "require": { - "php": "^8.4", + "php": "^8.3", "ext-pdo": "*", "ext-json": "*", "ext-readline": "*", diff --git a/core/composer.json b/core/composer.json index 5e785fb009..353da92301 100644 --- a/core/composer.json +++ b/core/composer.json @@ -1,7 +1,7 @@ { "name": "evolution-cms/evolution", "type": "project", - "version": "3.6.0", + "version": "3.5.9", "description": "Welcome to the Evolution CMS. The world’s fastest, most customizable Open Source PHP CMS. Your creative vision, no restrictions, no compromise.", "keywords": [ "content", @@ -27,7 +27,7 @@ } ], "require": { - "php": "^8.4", + "php": "^8.3", "composer/composer": "*", "dg/rss-php": "^1.7", "dmitry-suffi/redis-session-handler": "*", diff --git a/core/factory/version.php b/core/factory/version.php index f8711994d2..7678d134e1 100644 --- a/core/factory/version.php +++ b/core/factory/version.php @@ -1,6 +1,6 @@ '3.6.0', + 'version' => '3.5.9', 'release_date' => 'Oct 21, 2026', 'branch' => 'Evolution CMS', - 'full_appname' => 'Evolution CMS 3.6.0 (Oct 21, 2026)' + 'full_appname' => 'Evolution CMS 3.5.9 (Oct 21, 2026)' ]; diff --git a/publiccode.yml b/publiccode.yml index 8592cbe1c8..7785ccca3c 100644 --- a/publiccode.yml +++ b/publiccode.yml @@ -15,7 +15,7 @@ categories: developmentStatus: stable softwareType: standalone/web -softwareVersion: "3.6.x" +softwareVersion: "3.5.9" releaseDate: "2026-10-21" isBasedOn: @@ -45,7 +45,7 @@ description: Evolution CMS is an open-source content management system and web application framework written in PHP. The project originates from MODX Evolution (2004) and has been continuously developed by an international community. - The 3.6 series is based on Laravel 12 components and PHP 8.4+. + The 3.5 series is based on Laravel 12 and PHP 8.3+. The system provides flexible content modeling, granular permissions, and multi-site architecture. It supports multilingual websites and is suitable for public sector portals, intranets, and institutional websites. @@ -66,7 +66,7 @@ description: longDescription: | Evolution CMS — це система керування контентом з відкритим кодом, написана на PHP. - Проєкт розвивається міжнародною спільнотою та базується на компонентах Laravel 12 і PHP 8.4+. + Проєкт розвивається міжнародною спільнотою та базується на Laravel 12 і PHP 8.3+. legal: license: "GPL-3.0-or-later" @@ -114,7 +114,7 @@ localisation: dependsOn: open: - name: "PHP" - versionMin: "8.4" + versionMin: "8.3" optional: false - name: "Laravel" versionMin: "12.0" From d9aa2e5c45d3095932e48026c0210389602ccfe0 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 26 Oct 2026 02:27:41 +0100 Subject: [PATCH 2/4] fix(ci): dynamic php version based build --- .github/docker/Dockerfile | 3 ++- .github/docker/ci/README.md | 10 +++++--- .github/scripts/php-versions.sh | 45 +++++++++++++++++++++++++++++++++ .github/workflows/build.yml | 34 +++++++++++++++++++------ .github/workflows/ci.yml | 32 ++++++++++++++++++++--- .github/workflows/upgrade.yml | 19 +++++++++++--- 6 files changed, 123 insertions(+), 20 deletions(-) create mode 100755 .github/scripts/php-versions.sh diff --git a/.github/docker/Dockerfile b/.github/docker/Dockerfile index 7e4aca7cb0..331f0b0bf2 100644 --- a/.github/docker/Dockerfile +++ b/.github/docker/Dockerfile @@ -3,7 +3,8 @@ # The base image is built from evolution-cms/salo2 runtimes/ (Salo # is the Evolution CMS flavour of Laravel Sail): php:-apache with gd, # zip, pdo and mod_rewrite. sqlite3/pdo_sqlite come enabled in the official php -# images. The workflow builds 8.3, 8.4 and 8.5 and overrides BASE_IMAGE per leg. +# images. The workflow builds every version .github/scripts/php-versions.sh lists +# for the tree (8.3 too below CMS 3.6) and overrides BASE_IMAGE per leg. ARG BASE_IMAGE=evo-salo-runtime:8.4 FROM ${BASE_IMAGE} diff --git a/.github/docker/ci/README.md b/.github/docker/ci/README.md index 4aad2c0d19..298ceeac27 100644 --- a/.github/docker/ci/README.md +++ b/.github/docker/ci/README.md @@ -79,11 +79,13 @@ no PHP on it, and what pins the PHP version when a version question is the one being investigated. One PHP version, every database: what the job proves is the installer's database -paths, which do not vary with the PHP minor. PHP 8.3 through 8.5 are covered by -the analysis and unit test jobs. The sqlite leg starts no container. +paths, which do not vary with the PHP minor, and it runs on the lowest one +`.github/scripts/php-versions.sh` lists. The analysis and unit test jobs cover +all of them: 8.4 and 8.5, plus 8.3 while the CMS version is below 3.6. The +sqlite leg starts no container. -The same workflow also builds this image from the official `php:8.6-rc-cli` -image and runs the unit tests in it. The `8.6-rc` tag follows the latest PHP 8.6 +From CMS 3.6 on (the 3.5 line skips it), the same workflow also builds this +image from the official `php:8.6-rc-cli` image and runs the unit tests in it. The `8.6-rc` tag follows the latest PHP 8.6 release candidate; it currently resolves to PHP 8.6.0RC3. The image includes SQLite and mbstring support for the test suite. This prerelease compatibility job reports the build, version and test results in the Actions summary and as a diff --git a/.github/scripts/php-versions.sh b/.github/scripts/php-versions.sh new file mode 100755 index 0000000000..eea038faee --- /dev/null +++ b/.github/scripts/php-versions.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# The PHP versions CI runs a tree on, decided by the CMS version of that tree: +# 3.6 requires PHP 8.4, the 3.5 line still supports 8.3, and the PHP 8.6 release +# candidate is a 3.6 concern only. So one set of workflows keeps testing 8.3 on +# 3.5.x and moves to 8.6 on 3.6.x, whichever branch it was merged to. +# +# Usage: php-versions.sh [tree] (default: the current directory) +# Writes to $GITHUB_OUTPUT when set, to stdout otherwise: +# versions JSON list for a job matrix, lowest first +# lowest the lowest of them, for jobs that run on one version only +# rc the prerelease version to check as well, empty for none +set -eu + +tree=${1:-.} +file="$tree/core/factory/version.php" +version=$(sed -n "s/^[[:space:]]*'version'[[:space:]]*=>[[:space:]]*'\([0-9][0-9.]*\)'.*/\1/p" "$file" | head -n 1) +if [ -z "$version" ]; then + echo "::error file=$file::no 'version' => 'x.y.z' entry" >&2 + exit 1 +fi + +major=${version%%.*} +rest=${version#*.} +minor=${rest%%.*} +if [ "$major" -gt 3 ] || { [ "$major" -eq 3 ] && [ "$minor" -ge 6 ]; }; then + set -- 8.4 8.5 + rc=8.6 +else + set -- 8.3 8.4 8.5 + rc= +fi + +lowest=$1 +versions=$(printf '"%s",' "$@") +versions="[${versions%,}]" + +result="versions=$versions +lowest=$lowest +rc=$rc" +if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "$result" >> "$GITHUB_OUTPUT" +else + echo "$result" +fi +echo "CMS $version: PHP $*${rc:+, $rc RC}" >&2 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2a3cf56d4..f5b5aa2864 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,8 +44,26 @@ env: SALO2_REF: master jobs: + php: + name: PHP versions + runs-on: ubuntu-latest + outputs: + versions: ${{ steps.php.outputs.versions }} + lowest: ${{ steps.php.outputs.lowest }} + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + .github/scripts + core/factory + + # 8.3 below CMS 3.6 only, see the script + - id: php + run: sh .github/scripts/php-versions.sh + zip: name: Installed CMS (zip, sqlite) + needs: php runs-on: ubuntu-latest permissions: contents: write # required to publish the nightly release @@ -58,8 +76,8 @@ jobs: uses: shivammathur/setup-php@v2 with: # Built on the lowest supported version so the vendor tree stays - # usable on 8.3 and 8.4 alike. - php-version: '8.3' + # usable on every version the docker job runs it on. + php-version: ${{ needs.php.outputs.lowest }} extensions: ctype, dom, fileinfo, filter, iconv, intl, json, mbstring, openssl, pdo, pdo_sqlite, readline, simplexml, sqlite3, tokenizer, xml, xmlreader, zip coverage: none tools: composer:v2 @@ -72,8 +90,8 @@ jobs: uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} - key: composer-${{ runner.os }}-php8.3-build-${{ hashFiles('core/composer.lock') }} - restore-keys: composer-${{ runner.os }}-php8.3-build- + key: composer-${{ runner.os }}-php${{ needs.php.outputs.lowest }}-build-${{ hashFiles('core/composer.lock') }} + restore-keys: composer-${{ runner.os }}-php${{ needs.php.outputs.lowest }}-build- - name: Install core dependencies working-directory: core @@ -169,7 +187,7 @@ jobs: docker: name: Docker image PHP ${{ matrix.php }} ${{ matrix.server }} (GHCR) - needs: zip + needs: [php, zip] # Pull requests get a read-only token and cannot push packages, so the job is # skipped there — including on the run that stays attached to a merged PR. # The push to the base branch, and workflow_dispatch, are what build the image. @@ -183,9 +201,9 @@ jobs: # the ones that did build stay usable. fail-fast: false matrix: - # 8.3 is the lowest supported version and the one the vendor tree in the - # zip is built on, so it proves the archive is portable upwards. - php: ['8.3', '8.4', '8.5'] + # The lowest of them is the one the vendor tree in the zip is built on, + # so the others prove the archive is portable upwards. + php: ${{ fromJSON(needs.php.outputs.versions) }} # Three different PHP SAPIs, not three front ends over one PHP: apache # runs mod_php, nginx talks FastCGI to php-fpm, and FrankenPHP embeds # PHP inside Caddy. They cannot share a base image, so each is a build. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d04b1fd95..a3b5326ef7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,13 +18,32 @@ env: PHP_EXTENSIONS: ctype, dom, fileinfo, filter, iconv, intl, json, mbstring, openssl, pdo, pdo_sqlite, readline, simplexml, tokenizer, xml, xmlreader, zip jobs: + php: + name: PHP versions + runs-on: ubuntu-latest + outputs: + versions: ${{ steps.php.outputs.versions }} + lowest: ${{ steps.php.outputs.lowest }} + rc: ${{ steps.php.outputs.rc }} + steps: + - uses: actions/checkout@v7 + with: + sparse-checkout: | + .github/scripts + core/factory + + # 8.3 below CMS 3.6 only, 8.6 RC from 3.6 only, see the script + - id: php + run: sh .github/scripts/php-versions.sh + analyze: name: Static analysis (PHP ${{ matrix.php }}) + needs: php runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php: ['8.3', '8.4', '8.5'] + php: ${{ fromJSON(needs.php.outputs.versions) }} steps: - uses: actions/checkout@v7 @@ -57,11 +76,12 @@ jobs: tests: name: Unit tests (PHP ${{ matrix.php }}) + needs: php runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php: ['8.3', '8.4', '8.5'] + php: ${{ fromJSON(needs.php.outputs.versions) }} steps: - uses: actions/checkout@v7 @@ -94,6 +114,8 @@ jobs: php86-rc: name: PHP 8.6 RC compatibility (Docker) + needs: php + if: needs.php.outputs.rc == '8.6' runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -143,6 +165,7 @@ jobs: install: name: Install on ${{ matrix.db }} + needs: php runs-on: ubuntu-latest strategy: fail-fast: false @@ -184,7 +207,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ needs.php.outputs.lowest }} extensions: ${{ env.PHP_EXTENSIONS }}, pdo_mysql, pdo_pgsql, sqlite3 ini-values: error_reporting=E_ALL, display_errors=On, display_startup_errors=On, log_errors=On, error_log=/dev/stderr coverage: none @@ -215,6 +238,7 @@ jobs: audit: name: Dependency audit + needs: php runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -224,7 +248,7 @@ jobs: with: # The audit reads composer.lock and the advisory database; it does not run the code, # so one version is enough. Kept at the lowest supported one for consistency. - php-version: '8.3' + php-version: ${{ needs.php.outputs.lowest }} extensions: ${{ env.PHP_EXTENSIONS }} coverage: none tools: composer:v2 diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml index 8924575561..74839c0a2e 100644 --- a/.github/workflows/upgrade.yml +++ b/.github/workflows/upgrade.yml @@ -110,12 +110,25 @@ jobs: if: matrix.leg.db != 'sqlite' run: docker compose -f "new/$COMPOSE" --profile ${{ matrix.leg.db }} up -d --wait ${{ matrix.leg.db }} - # 8.3 is the lowest version both trees support, so it is the one that can - # run the old installer and the new updater in the same job. + # The script comes from this workflow's own commit: a "to" ref may be older + # than the script. + - name: Check out the CI scripts + uses: actions/checkout@v7 + with: + path: ci + sparse-checkout: .github/scripts + + # The lowest version the new tree supports (8.3 below CMS 3.6, 8.4 from + # 3.6), which the older trees run on too, so one job can run the old + # installer and the new updater. + - name: PHP version of the new tree + id: php + run: sh ci/.github/scripts/php-versions.sh new + - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.3' + php-version: ${{ steps.php.outputs.lowest }} extensions: ctype, dom, fileinfo, filter, iconv, intl, json, mbstring, openssl, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, readline, simplexml, sqlite3, tokenizer, xml, xmlreader, zip ini-values: error_reporting=E_ALL, display_errors=On, display_startup_errors=On, log_errors=On, error_log=/dev/stderr coverage: none From a39eb0f8fc620b4fde32dc37d04b641941bc3a72 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 26 Oct 2026 02:27:58 +0100 Subject: [PATCH 3/4] fix(manager): tree selection sync --- core/tests/Unit/CiPhpVersionsTest.php | 77 ++++ .../media/browser/mcpuk/js/browser/files.js | 12 +- .../media/browser/mcpuk/js/browser/folders.js | 168 +++++++-- .../media/browser/mcpuk/js/browser/misc.js | 18 + .../js/tests/selection-shortcuts.test.js | 5 + .../js/tests/tree-selection-sync.test.js | 350 ++++++++++++++++++ .../media/browser/mcpuk/themes/evo/style.css | 2 + .../browser/mcpuk/themes/oxygen/style.css | 7 + 8 files changed, 595 insertions(+), 44 deletions(-) create mode 100644 core/tests/Unit/CiPhpVersionsTest.php create mode 100644 manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js diff --git a/core/tests/Unit/CiPhpVersionsTest.php b/core/tests/Unit/CiPhpVersionsTest.php new file mode 100644 index 0000000000..cb552ae2f0 --- /dev/null +++ b/core/tests/Unit/CiPhpVersionsTest.php @@ -0,0 +1,77 @@ +markTestSkipped('No .github in this tree.'); + } + evoRunCommand('sh -c true', $exitCode); + if ($exitCode !== 0) { + test()->markTestSkipped('No POSIX sh.'); + } + + return $script; +} + +function ciPhpVersionsFor(string $versionFile, ?int &$exitCode = null): string +{ + $tree = sys_get_temp_dir() . '/evo-ci-php-' . bin2hex(random_bytes(4)); + mkdir($tree . '/core/factory', 0777, true); + file_put_contents($tree . '/core/factory/version.php', $versionFile); + try { + return evoRunCommand( + 'sh ' . escapeshellarg(ciPhpVersionsScript()) . ' ' . escapeshellarg($tree) . ' 2>&1', + $exitCode + ); + } finally { + unlink($tree . '/core/factory/version.php'); + rmdir($tree . '/core/factory'); + rmdir($tree . '/core'); + rmdir($tree); + } +} + +test('the CMS version decides whether CI runs PHP 8.3 or the 8.6 RC', function (string $version, string $versions, string $lowest, string $rc) { + $output = ciPhpVersionsFor(" '$version',\n 'release_date' => 'Oct 21, 2026',\n];\n", $exitCode); + + expect($exitCode)->toBe(0) + ->and($output)->toContain("versions=$versions\nlowest=$lowest\nrc=$rc\n"); +})->with([ + '3.5 line' => ['3.5.9', '["8.3","8.4","8.5"]', '8.3', ''], + 'two-digit 3.5 patch' => ['3.5.10', '["8.3","8.4","8.5"]', '8.3', ''], + '3.6' => ['3.6.0', '["8.4","8.5"]', '8.4', '8.6'], + 'two-digit minor' => ['3.10.1', '["8.4","8.5"]', '8.4', '8.6'], + 'next major' => ['4.0.0', '["8.4","8.5"]', '8.4', '8.6'], +]); + +test('the 8.6 RC job runs only where the script asks for it', function () { + ciPhpVersionsScript(); + $ci = (string) file_get_contents(dirname(__DIR__, 3) . '/.github/workflows/ci.yml'); + + expect($ci)->toContain(" name: PHP 8.6 RC compatibility (Docker)\n needs: php\n if: needs.php.outputs.rc == '8.6'\n"); +}); + +test('a tree without a readable version fails instead of guessing', function () { + $output = ciPhpVersionsFor("not->toBe(0) + ->and($output)->not->toContain('versions='); +}); + +test('no workflow lists PHP versions of its own', function () { + ciPhpVersionsScript(); + $workflows = glob(dirname(__DIR__, 3) . '/.github/workflows/*.yml'); + + expect($workflows)->not->toBeEmpty(); + foreach ($workflows as $workflow) { + $yaml = (string) file_get_contents($workflow); + expect($yaml)->not->toMatch('/^\s*php:\s*\[/m', basename($workflow)) + ->and($yaml)->not->toMatch("/php-version:\\s*'?8\\./", basename($workflow)); + } +}); diff --git a/manager/media/browser/mcpuk/js/browser/files.js b/manager/media/browser/mcpuk/js/browser/files.js index bee2f1b8fb..231e15eccf 100755 --- a/manager/media/browser/mcpuk/js/browser/files.js +++ b/manager/media/browser/mcpuk/js/browser/files.js @@ -92,6 +92,7 @@ browser.initFiles = function() { }); this.statusDir(); this.updateDeleteButton(); + this.syncTreeSelection(); lazyLoadInstance.update(); this.updateMobileActions(); }; @@ -116,6 +117,7 @@ browser.selectFolder = function(file) { file.addClass('selected'); this.statusDir(); this.updateDeleteButton(); + this.syncTreeSelection(); this.updateMobileActions(); }; @@ -223,6 +225,7 @@ browser.clearSelection = function() { this.lastSelectedFile = null; this.statusDir(); this.updateDeleteButton(); + this.syncTreeSelection(); this.updateMobileActions(); }; @@ -232,6 +235,7 @@ browser.updateSelectionStatus = function() { }).get(); var size = 0; + this.syncTreeSelection(); if (!files.length) { this.statusDir(); this.updateDeleteButton(); @@ -504,9 +508,7 @@ browser.selectionAnchorIndex = function(file, files, targetIndex) { }; browser.openDir = function(file) { - var name = file.data('name'); - var path = browser.dir.length ? browser.dir + '/' + name : name; - var dir = $('#folders a[href="kcdir:/' + _.escapeDirs(path) + '"]'); + var dir = this.treeSubDir(file.data('name')); if (!dir.get(0)) return false; @@ -517,8 +519,7 @@ browser.openDir = function(file) { browser.menuFolder = function(file, e) { var name = file.data('name'); - var path = browser.dir.length ? browser.dir + '/' + name : name; - var dir = $('#folders a[href="kcdir:/' + _.escapeDirs(path) + '"]'); + var dir = this.treeSubDir(name); if (!dir.get(0)) return browser.openDir(file); @@ -526,6 +527,7 @@ browser.menuFolder = function(file, e) { $('.file').removeClass('selected'); file.addClass('selected'); this.updateDeleteButton(); + this.syncTreeSelection(); $('#fileinfo').text(name); browser.menuDir(dir, e); return true; diff --git a/manager/media/browser/mcpuk/js/browser/folders.js b/manager/media/browser/mcpuk/js/browser/folders.js index f78ba00970..707e397557 100755 --- a/manager/media/browser/mcpuk/js/browser/folders.js +++ b/manager/media/browser/mcpuk/js/browser/folders.js @@ -28,6 +28,7 @@ browser.initFolders = function() { $('div.folder > a > span.folder').unbind(); $('div.folder > a > span.folder').rightClick(function(e) { _.unselect(); + browser.selectListedFolder($(this).parent()); browser.menuDir($(this).parent(), e); }); @@ -53,6 +54,78 @@ browser.openTreeFolder = function(dir) { this.changeDir(dir); }; +// a folder's node in the tree; empty while the branch holding it is not loaded +browser.treeDir = function(path) { + return $('#folders a[href="kcdir:/' + _.escapeDirs(path) + '"]'); +}; + +browser.subDirPath = function(name) { + return this.dir.length ? this.dir + '/' + name : name; +}; + +// the subfolder of dir that leads to path: ("images", "images/team/2024") gives "team" +browser.subDirOnPath = function(dir, path) { + if ((typeof path != 'string') || (path.indexOf(dir + '/') !== 0)) + return undefined; + return path.substr(dir.length + 1).split('/')[0]; +}; + +/** + * The tree node of a folder listed in the file list. A tree folder opened by a click is not expanded, + * so its branch is loaded here when it never was: every listed folder has a node to open or mark. + */ +browser.treeSubDir = function(name) { + var dir = this.treeDir(this.subDirPath(name)); + if (dir.get(0)) + return dir; + + var current = this.treeDir(this.dir); + if (current.get(0) && !current.parent().children('.folders').get(0) && this.loadTreeBranch(current)) + dir = this.treeDir(this.subDirPath(name)); + return dir; +}; + +// opens the collapsed branches above a tree node and scrolls the tree to it +browser.revealTreeDir = function(dir) { + dir.parents('div.folders').each(function() { + var folders = $(this); + if (folders.css('display') != 'none') + return; + folders.css('display', ''); + folders.parent().children('a').first().children('.brace').removeClass('closed').addClass('opened'); + }); + this.scrollIntoPane('#folders', dir.children('span.folder').get(0)); +}; + +// the folders selected in the file list are marked in the tree too: both panes show one selection +browser.syncTreeSelection = function() { + $('div.folder > a > span.folder').removeClass('selected'); + $('#files .file.selected').each(function() { + var file = $(this); + if (!file.data('isDir')) + return; + var dir = browser.treeSubDir(file.data('name')); + if (!dir.get(0)) + return; + dir.children('span.folder').addClass('selected'); + browser.revealTreeDir(dir); + }); +}; + +// a tree folder that the file list shows (a subfolder of the folder on screen) is selected there too +browser.selectListedFolder = function(dir) { + var path = dir.data('path'); + var file = $('#files .file').filter(function() { + return $(this).data('isDir') && (browser.subDirPath($(this).data('name')) === path); + }).first(); + if (!file.get(0)) + return false; + + this.selectFolder(file); + this.scrollIntoPane('#files', file.get(0)); + return true; +}; + browser.toggleTreeFolder = function(dir) { var brace = dir.children('.brace'); if (brace.hasClass('opened') || brace.hasClass('closed')) @@ -152,57 +225,71 @@ browser.expandDir = function(dir) { dir.parent().append(this.loadingHtml('loadingDirs')); // a moment for the indicator to show before the request holds the page setTimeout(function() { - $.ajax({ - type: 'POST', - dataType: 'json', - url: browser.baseGetData('expand'), - data: {dir:path}, - async: false, - success: function(data) { - $('#loadingDirs').detach(); - if (browser.check4errors(data)) - return; - - var html = ''; - $.each(data.dirs, function(i, cdir) { - html += ''; - }); - if (html.length) { - dir.parent().append('
' + html + '
'); - var folders = $(dir.parent().children('.folders').first()); - folders.css('display', 'none'); - $(folders).show(500); - $.each(data.dirs, function(i, cdir) { - browser.setTreeData(cdir, path); - }); - } - if (data.dirs.length) { - dir.children('.brace').removeClass('closed'); - dir.children('.brace').addClass('opened'); - } else { - dir.children('.brace').removeClass('opened'); - dir.children('.brace').removeClass('closed'); - } - browser.initFolders(); - }, - error: function() { - $('#loadingDirs').detach(); - browser.alert(browser.label("Unknown error.")); - } - }); + browser.loadTreeBranch(dir, true); + $('#loadingDirs').detach(); + browser.syncTreeSelection(); }, 0); } } }; +// reads the subfolders of a tree node into the tree; false when the request fails +browser.loadTreeBranch = function(dir, animate) { + var path = dir.data('path'); + var loaded = false; + $.ajax({ + type: 'POST', + dataType: 'json', + url: browser.baseGetData('expand'), + data: {dir:path}, + async: false, + success: function(data) { + if (browser.check4errors(data)) + return; + + var html = ''; + $.each(data.dirs, function(i, cdir) { + html += ''; + }); + if (html.length) { + dir.parent().append('
' + html + '
'); + if (animate) { + var folders = $(dir.parent().children('.folders').first()); + folders.css('display', 'none'); + $(folders).show(500); + } + $.each(data.dirs, function(i, cdir) { + browser.setTreeData(cdir, path); + }); + } + if (data.dirs.length) { + dir.children('.brace').removeClass('closed'); + dir.children('.brace').addClass('opened'); + } else { + dir.children('.brace').removeClass('opened'); + dir.children('.brace').removeClass('closed'); + } + browser.initFolders(); + loaded = true; + }, + error: function() { + browser.alert(browser.label("Unknown error.")); + } + }); + return loaded; +}; + browser.changeDir = function(dir) { if (dir.children('span.folder').hasClass('regular')) { + var previous = this.dir; this.clearMobileActions(); $('div.folder > a > span.folder').removeClass('current'); + $('div.folder > a > span.folder').removeClass('selected'); $('div.folder > a > span.folder').removeClass('regular'); $('div.folder > a > span.folder').addClass('regular'); dir.children('span.folder').removeClass('regular'); dir.children('span.folder').addClass('current'); + this.revealTreeDir(dir); $('#files').html(browser.loadingHtml()); $.ajax({ type: 'POST', @@ -215,8 +302,11 @@ browser.changeDir = function(dir) { return; browser.files = data.files; browser.links = data.links ? data.links : []; - browser.orderFiles(); browser.dir = dir.data('path'); + // up the tree, the folder it came from is selected in the list (and so in the tree) + browser.orderFiles(function() { + browser.scrollIntoPane('#files', $('#files .file.selected').get(0)); + }, browser.subDirOnPath(browser.dir, previous)); browser.dirWritable = data.dirWritable; browser.updateWriteControls(); var title = "KCFinder: /" + browser.dir; diff --git a/manager/media/browser/mcpuk/js/browser/misc.js b/manager/media/browser/mcpuk/js/browser/misc.js index faab4f324a..46969dbdee 100755 --- a/manager/media/browser/mcpuk/js/browser/misc.js +++ b/manager/media/browser/mcpuk/js/browser/misc.js @@ -413,3 +413,21 @@ browser.fadeFiles = function() { filter: 'alpha(opacity:40)' }); }; + +// scrolls a pane just enough to show an element; not scrollIntoView(), which also moves the editor page around an iframe +browser.scrollIntoPane = function(pane, element) { + pane = $(pane).get(0); + if (!pane || !element || !element.getBoundingClientRect) + return; + + var view = pane.getBoundingClientRect(); + var box = element.getBoundingClientRect(); + if (box.top < view.top) + pane.scrollTop -= view.top - box.top; + else if (box.bottom > view.bottom) + pane.scrollTop += Math.min(box.bottom - view.bottom, box.top - view.top); + if (box.left < view.left) + pane.scrollLeft -= view.left - box.left; + else if (box.right > view.right) + pane.scrollLeft += Math.min(box.right - view.right, box.left - view.left); +}; diff --git a/manager/media/browser/mcpuk/js/tests/selection-shortcuts.test.js b/manager/media/browser/mcpuk/js/tests/selection-shortcuts.test.js index 7f1f81951c..093e9665ed 100644 --- a/manager/media/browser/mcpuk/js/tests/selection-shortcuts.test.js +++ b/manager/media/browser/mcpuk/js/tests/selection-shortcuts.test.js @@ -51,6 +51,7 @@ function loadBrowser() { vm.runInNewContext(source, { browser, $ }); vm.runInNewContext(toolbarSource, { browser, $ }); browser.updateMobileActions = () => {}; + browser.syncTreeSelection = () => {}; return browser; } @@ -86,6 +87,7 @@ function loadFileSelection() { browser.updateDeleteButton = () => {}; browser.updateSelectionStatus = () => {}; browser.updateMobileActions = () => {}; + browser.syncTreeSelection = () => {}; return { browser, files, wrap }; } @@ -259,9 +261,11 @@ test('clearing selection removes its highlight and resets the range anchor', () const browser = loadBrowser(); let statusCalls = 0; let buttonCalls = 0; + let treeCalls = 0; browser.lastSelectedFile = {}; browser.statusDir = () => statusCalls++; browser.updateDeleteButton = () => buttonCalls++; + browser.syncTreeSelection = () => treeCalls++; browser.clearSelection(); @@ -269,4 +273,5 @@ test('clearing selection removes its highlight and resets the range anchor', () assert.equal(browser.lastSelectedFile, null); assert.equal(statusCalls, 1); assert.equal(buttonCalls, 1); + assert.equal(treeCalls, 1); }); diff --git a/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js b/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js new file mode 100644 index 0000000000..1c2979bb88 --- /dev/null +++ b/manager/media/browser/mcpuk/js/tests/tree-selection-sync.test.js @@ -0,0 +1,350 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const read = (file) => fs.readFileSync(path.join(__dirname, '..', 'browser', file), 'utf8'); +const foldersSource = read('folders.js'); +const filesSource = read('files.js'); +const miscSource = read('misc.js'); + +// a few elements and just the jQuery the tree and the file list use +function el(tag, classes, parent) { + const node = { tag, cls: new Set(classes || []), attrs: {}, store: {}, style: {}, kids: [], parent: null }; + if (parent) { + node.parent = parent; + parent.kids.push(node); + } + return node; +} + +function matches(node, selector) { + const [tag, ...classes] = selector.split('.'); + return (!tag || node.tag === tag) && classes.every((name) => node.cls.has(name)); +} + +function descendants(node) { + return node.kids.flatMap((kid) => [kid, ...descendants(kid)]); +} + +function wrap(nodes) { + const set = { + length: nodes.length, + get: (i) => nodes[i], + first: () => wrap(nodes.slice(0, 1)), + each(fn) { nodes.forEach((node, i) => fn.call(node, i, node)); return set; }, + filter: (fn) => wrap(nodes.filter((node, i) => fn.call(node, i, node))), + children: (selector) => wrap(nodes.flatMap((node) => node.kids.filter((kid) => !selector || matches(kid, selector)))), + parent: () => wrap(nodes.map((node) => node.parent).filter(Boolean)), + parents(selector) { + const found = []; + nodes.forEach((node) => { + for (let up = node.parent; up; up = up.parent) + if (matches(up, selector)) found.push(up); + }); + return wrap(found); + }, + hasClass: (name) => nodes.some((node) => node.cls.has(name)), + addClass(name) { nodes.forEach((node) => node.cls.add(name)); return set; }, + removeClass(name) { nodes.forEach((node) => node.cls.delete(name)); return set; }, + css(key, value) { + if (typeof value == 'undefined') return nodes[0] ? (nodes[0].style[key] || '') : undefined; + nodes.forEach((node) => { node.style[key] = value; }); + return set; + }, + data(key, value) { + const node = nodes[0]; + if (typeof key == 'undefined') return node.store; + if (typeof key == 'object') { nodes.forEach((n) => Object.assign(n.store, key)); return set; } + if (typeof value == 'undefined') return node ? node.store[key] : undefined; + nodes.forEach((n) => { n.store[key] = value; }); + return set; + }, + html() { return set; }, + text() { return set; }, + }; + return set; +} + +function treeNode(container, dirPath, opts = {}) { + const folder = el('div', ['folder'], container); + const a = el('a', [], folder); + a.attrs.href = 'kcdir:/' + dirPath; + a.store = { path: dirPath, name: dirPath.split('/').pop(), hasDirs: !!opts.hasDirs }; + el('span', ['brace'].concat(opts.brace ? [opts.brace] : []), a); + el('span', ['folder', opts.current ? 'current' : 'regular'], a); + return { folder, a }; +} + +function subfolders(node, hidden) { + const folders = el('div', ['folders'], node.folder); + if (hidden) folders.style.display = 'none'; + return folders; +} + +function setup() { + const root = el('div', [], null); + const tree = el('div', [], root); + const files = el('div', [], root); + + const images = treeNode(tree, 'images', { current: true, hasDirs: true, brace: 'opened' }); + + const page = { + root, tree, files, images, + card(name, isDir, selected) { + const card = el('div', ['file'].concat(isDir ? ['folder'] : []).concat(selected ? ['selected'] : []), files); + card.store = { name, isDir }; + return card; + }, + treeSpans: () => descendants(tree).filter((n) => n.tag === 'span' && n.cls.has('folder')), + selectedInTree: () => page.treeSpans().filter((n) => n.cls.has('selected')).map((n) => n.parent.store.path), + $: null, + }; + + const $ = (selector) => { + if (selector && typeof selector == 'object') return wrap([selector]); + let match = /^#folders a\[href="(.*)"\]$/.exec(selector); + if (match) return wrap(descendants(tree).filter((n) => n.tag === 'a' && n.attrs.href === match[1])); + if (selector === 'div.folder > a > span.folder') return wrap(page.treeSpans()); + if (selector === '#files .file.selected') return wrap(files.kids.filter((n) => n.cls.has('file') && n.cls.has('selected'))); + if (selector === '#files .file' || selector === '.file') return wrap(files.kids.filter((n) => n.cls.has('file'))); + if (selector === '#files') return wrap([files]); + if (selector === '#folders') return wrap([tree]); + return wrap([]); + }; + $.each = (items, fn) => (Array.isArray(items) ? items : Object.values(items)).forEach((item, i) => fn(i, item)); + page.$ = $; + + const browser = {}; + const context = { browser, $, document: {}, _: { unselect() {}, escapeDirs: (p) => p, htmlData: (t) => t } }; + vm.runInNewContext(foldersSource, context); + vm.runInNewContext(filesSource, context); + Object.assign(browser, { + dir: 'images', + scrolled: [], + scrollIntoPane(pane, element) { browser.scrolled.push([pane, element]); }, + statusDir() {}, + updateDeleteButton() {}, + updateMobileActions() {}, + clearMobileActions() {}, + }); + + return { browser, page }; +} + +test('the subfolder of a folder that leads to a deeper path', () => { + const { browser } = setup(); + + assert.equal(browser.subDirOnPath('images', 'images/team/2024'), 'team'); + assert.equal(browser.subDirOnPath('images', 'images/team'), 'team'); + assert.equal(browser.subDirOnPath('images', 'images'), undefined); + assert.equal(browser.subDirOnPath('images', 'images-old/team'), undefined); + assert.equal(browser.subDirOnPath('images/team', 'images'), undefined); + assert.equal(browser.subDirOnPath('images', undefined), undefined); +}); + +test('a listed folder is found in the tree without loading anything when its branch is there', () => { + const { browser, page } = setup(); + treeNode(subfolders(page.images), 'images/team'); + let loads = 0; + browser.loadTreeBranch = () => { loads++; return true; }; + + assert.equal(browser.treeSubDir('team').get(0).store.path, 'images/team'); + assert.equal(loads, 0); +}); + +test('a listed folder whose branch was never loaded loads the branch of the folder on screen', () => { + const { browser, page } = setup(); + const loaded = []; + browser.loadTreeBranch = (dir) => { + loaded.push(dir.data('path')); + treeNode(subfolders(page.images), 'images/team'); + return true; + }; + + assert.equal(browser.treeSubDir('team').get(0).store.path, 'images/team'); + assert.deepEqual(loaded, ['images']); +}); + +test('a loaded branch without the folder is not read again on every selection', () => { + const { browser, page } = setup(); + treeNode(subfolders(page.images), 'images/other'); + let loads = 0; + browser.loadTreeBranch = () => { loads++; return true; }; + + assert.equal(browser.treeSubDir('team').length, 0); + assert.equal(loads, 0); +}); + +test('a folder selected in the file list is selected in the tree, and only that one', () => { + const { browser, page } = setup(); + const folders = subfolders(page.images); + treeNode(folders, 'images/team'); + const news = treeNode(folders, 'images/news'); + news.a.kids[1].cls.add('selected'); + page.card('team', true, true); + page.card('news', true, false); + page.card('photo.jpg', false, true); + + browser.syncTreeSelection(); + + assert.deepEqual(page.selectedInTree(), ['images/team']); +}); + +test('clearing the file list selection clears the tree selection', () => { + const { browser, page } = setup(); + const team = treeNode(subfolders(page.images), 'images/team'); + team.a.kids[1].cls.add('selected'); + page.card('team', true, true); + + browser.clearSelection(); + + assert.deepEqual(page.selectedInTree(), []); +}); + +test('selecting a folder in the file list opens the collapsed tree branch that holds it', () => { + const { browser, page } = setup(); + page.images.a.kids[0].cls.delete('opened'); + page.images.a.kids[0].cls.add('closed'); + const team = treeNode(subfolders(page.images, true), 'images/team'); + const card = page.card('team', true, false); + + browser.selectFolder(wrap([card])); + + assert.deepEqual(page.selectedInTree(), ['images/team']); + assert.equal(page.images.folder.kids[1].style.display, ''); + assert.ok(page.images.a.kids[0].cls.has('opened')); + assert.ok(!page.images.a.kids[0].cls.has('closed')); + assert.deepEqual(browser.scrolled.at(-1), ['#folders', team.a.kids[1]]); +}); + +test('right-clicking a tree folder that the file list shows selects it there', () => { + const { browser, page } = setup(); + const team = treeNode(subfolders(page.images), 'images/team'); + page.card('photo.jpg', false, true); + const card = page.card('team', true, false); + + assert.equal(browser.selectListedFolder(wrap([team.a])), true); + + assert.ok(card.cls.has('selected')); + assert.ok(!page.files.kids[0].cls.has('selected')); + assert.deepEqual(page.selectedInTree(), ['images/team']); + assert.deepEqual(browser.scrolled.at(-1), ['#files', card]); +}); + +test('right-clicking a tree folder that the file list does not show leaves its selection alone', () => { + const { browser, page } = setup(); + const photo = page.card('photo.jpg', false, true); + + assert.equal(browser.selectListedFolder(wrap([page.images.a])), false); + assert.ok(photo.cls.has('selected')); +}); + +test('the tree context menu selects the folder in the file list before it opens', () => { + const { browser } = setup(); + const handlers = {}; + const calls = []; + const span = {}; + const chain = new Proxy(function () {}, { + get: (target, prop) => (prop === 'rightClick' + ? (fn) => { handlers.rightClick = fn; return chain; } + : () => chain), + }); + const context = { + browser, + $: Object.assign((target) => (target === span ? { parent: () => 'node' } : chain), { browser: {} }), + _: { unselect() {} }, + }; + vm.runInNewContext(foldersSource, context); + browser.selectListedFolder = (dir) => calls.push('select:' + dir); + browser.menuDir = (dir) => calls.push('menu:' + dir); + + browser.initFolders(); + handlers.rightClick.call(span, {}); + + assert.deepEqual(calls, ['select:node', 'menu:node']); +}); + +test('double-clicking a listed folder opens it even when the tree branch was not loaded', () => { + const { browser, page } = setup(); + const opened = []; + browser.loadTreeBranch = () => { + treeNode(subfolders(page.images), 'images/team'); + return true; + }; + browser.changeDir = (dir) => opened.push(dir.data('path')); + + assert.equal(browser.openDir(wrap([page.card('team', true, false)])), true); + assert.deepEqual(opened, ['images/team']); +}); + +function openWithChDir(browser, page, target) { + const ordered = []; + page.$.ajax = (options) => options.success({ files: [], links: [], dirWritable: true }); + browser.loadingHtml = () => ''; + browser.check4errors = () => false; + browser.updateWriteControls = () => {}; + browser.opener = {}; + browser.orderFiles = (callBack, selected) => ordered.push(selected); + browser.baseGetData = (act) => act; + browser.changeDir(wrap([target.a])); + return ordered; +} + +test('going up the tree selects the folder it came from in the file list', () => { + const { browser, page } = setup(); + page.images.a.kids[1].cls.delete('current'); + page.images.a.kids[1].cls.add('regular'); + browser.dir = 'images/team/2024'; + + const ordered = openWithChDir(browser, page, page.images); + + assert.deepEqual(ordered, ['team']); + assert.equal(browser.dir, 'images'); +}); + +test('going down or sideways selects nothing in the file list', () => { + const { browser, page } = setup(); + const folders = subfolders(page.images); + const team = treeNode(folders, 'images/team'); + const news = treeNode(folders, 'images/news'); + + assert.deepEqual(openWithChDir(browser, page, team), [undefined]); + assert.deepEqual(openWithChDir(browser, page, news), [undefined]); +}); + +test('opening a folder moves the tree highlight: current on it, nothing left selected', () => { + const { browser, page } = setup(); + const folders = subfolders(page.images, true); + const team = treeNode(folders, 'images/team'); + team.a.kids[1].cls.add('selected'); + + openWithChDir(browser, page, team); + + assert.ok(team.a.kids[1].cls.has('current')); + assert.deepEqual(page.selectedInTree(), []); + // a folder opened from the file list is shown in the tree as well + assert.equal(folders.style.display, ''); +}); + +test('a pane scrolls just enough to show an element, and only when it is outside', () => { + const browser = {}; + vm.runInNewContext(miscSource, { browser, $: (pane) => ({ get: () => pane }), _: {}, document: {}, window: {} }); + const rect = (top, bottom, left = 0, right = 100) => ({ top, bottom, left, right }); + const pane = { scrollTop: 50, scrollLeft: 0, getBoundingClientRect: () => rect(100, 300) }; + + browser.scrollIntoPane(pane, { getBoundingClientRect: () => rect(150, 170) }); + assert.equal(pane.scrollTop, 50); + + browser.scrollIntoPane(pane, { getBoundingClientRect: () => rect(80, 100) }); + assert.equal(pane.scrollTop, 30); + + browser.scrollIntoPane(pane, { getBoundingClientRect: () => rect(310, 330) }); + assert.equal(pane.scrollTop, 60); + + browser.scrollIntoPane(pane, { getBoundingClientRect: () => rect(150, 170, 90, 140) }); + assert.equal(pane.scrollLeft, 40); + + assert.doesNotThrow(() => browser.scrollIntoPane(pane, undefined)); +}); diff --git a/manager/media/browser/mcpuk/themes/evo/style.css b/manager/media/browser/mcpuk/themes/evo/style.css index f585538a9a..41fffe62b4 100644 --- a/manager/media/browser/mcpuk/themes/evo/style.css +++ b/manager/media/browser/mcpuk/themes/evo/style.css @@ -26,6 +26,8 @@ span.current:before { display: inline-block; font-family: 'Font Awesome 5 Free'; span.regular { background-color: #f8f7f6; } span.regular:before { display: inline-block; font-family: 'Font Awesome 5 Free'; content: "\f07b"; width: 1.3em; font-size: 1.3rem; line-height: .8em; vertical-align: middle; color: #565656; font-weight:900} span.regular:hover, span.context { background-color: #ddebf8; border-color: #cee0f4; color: #000; } +span.folder.selected, span.folder.selected:hover { background-color: #5b9bda; border-color: #2973bd; color: #fff; } +span.folder.selected:before { color: #fff; } span.opened:before { display: inline-block; font-family: 'Font Awesome 5 Free'; content: "\f107"; width: 1.3em; font-size: 1.3rem; line-height: .8em; vertical-align: middle; color: #565656; font-weight:900} span.closed:before { display: inline-block; font-family: 'Font Awesome 5 Free'; content: "\f105"; width: 1.3em; font-size: 1.3rem; line-height: .8em; vertical-align: middle; color: #565656; font-weight:900} span.denied:before { display: inline-block; font-family: 'Font Awesome 5 Free'; content: "\f05e"; width: 1.3em; font-size: 1.3rem; line-height: .8em; vertical-align: middle; color: #565656; font-weight:900} diff --git a/manager/media/browser/mcpuk/themes/oxygen/style.css b/manager/media/browser/mcpuk/themes/oxygen/style.css index 4aa3a2efe7..d9b2ed6e4e 100755 --- a/manager/media/browser/mcpuk/themes/oxygen/style.css +++ b/manager/media/browser/mcpuk/themes/oxygen/style.css @@ -160,6 +160,13 @@ span.regular:hover, span.context { color: #000; } +span.folder.selected, +span.folder.selected:hover { + background-color: #b9d4f0; + border-color: #2973bd; + color: #000; +} + span.opened { background-image: url(img/tree/minus.png); } From 87e04213855125bc766081e2e067355aefd2e605 Mon Sep 17 00:00:00 2001 From: Artur Kyryliuk Date: Mon, 26 Oct 2026 03:02:04 +0100 Subject: [PATCH 4/4] fix(ci): ci php version test --- core/tests/Unit/CiPhpVersionsTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/tests/Unit/CiPhpVersionsTest.php b/core/tests/Unit/CiPhpVersionsTest.php index cb552ae2f0..e1f4b67e95 100644 --- a/core/tests/Unit/CiPhpVersionsTest.php +++ b/core/tests/Unit/CiPhpVersionsTest.php @@ -24,12 +24,18 @@ function ciPhpVersionsFor(string $versionFile, ?int &$exitCode = null): string $tree = sys_get_temp_dir() . '/evo-ci-php-' . bin2hex(random_bytes(4)); mkdir($tree . '/core/factory', 0777, true); file_put_contents($tree . '/core/factory/version.php', $versionFile); + // Inside GitHub Actions the script would write to the job's own output file, not stdout. + $githubOutput = getenv('GITHUB_OUTPUT'); + putenv('GITHUB_OUTPUT'); try { return evoRunCommand( 'sh ' . escapeshellarg(ciPhpVersionsScript()) . ' ' . escapeshellarg($tree) . ' 2>&1', $exitCode ); } finally { + if ($githubOutput !== false) { + putenv('GITHUB_OUTPUT=' . $githubOutput); + } unlink($tree . '/core/factory/version.php'); rmdir($tree . '/core/factory'); rmdir($tree . '/core');