diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index cc966d37..f80f7c33 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -8,6 +8,8 @@ body: value: | Thanks for reporting this. Please include only the details needed to reproduce the issue quickly. + This issue is public. Export a **sanitized** support bundle before refreshing the affected page when possible, review its preview, and never attach credentials, tokens, cookies, private URLs, a full/unsanitized bundle, or a raw configuration export. + - type: checkboxes id: precheck attributes: @@ -15,7 +17,7 @@ body: options: - label: I searched existing issues and did not find an open duplicate. required: true - - label: I reproduced this on the latest version from this repository (main or latest commit URL). + - label: I reproduced this on the latest stable release, or I entered the exact requested dev/commit-specific version below. required: true - type: input @@ -34,6 +36,19 @@ body: validations: required: true + - type: dropdown + id: release_channel + attributes: + label: Installed release channel + options: + - Stable / main + - Dev testing build + - Commit-specific build + - Local or manually modified package + - Unknown + validations: + required: true + - type: input id: browser_device attributes: @@ -49,13 +64,42 @@ body: label: Install/update method options: - Community Applications - - plugin install (main URL) + - plugin install (stable/main URL) + - plugin install (dev URL) - plugin install (commit-specific URL) - Local file/manual - Other validations: required: true + - type: dropdown + id: access_method + attributes: + label: How do you access Unraid? + options: + - Direct LAN IP + - Local hostname + - Reverse proxy or custom domain + - Unraid Connect + - Tailscale or another VPN + - Other or unsure + validations: + required: true + + - type: dropdown + id: docker_view_mode + attributes: + label: Unraid Docker view mode + description: Select Not applicable when this is not a Docker-page issue. + options: + - Basic View + - Advanced View + - Tested in both + - Not applicable + - Unsure + validations: + required: true + - type: dropdown id: theme_context attributes: @@ -82,6 +126,20 @@ body: validations: required: true + - type: dropdown + id: configuration_origin + attributes: + label: Configuration origin + options: + - Fresh FolderView Plus configuration + - Upgraded from an earlier FolderView Plus version + - Imported from FolderView3 + - Imported from FolderView2 + - Restored from a backup or export + - Unsure + validations: + required: true + - type: dropdown id: affected_area attributes: @@ -89,6 +147,9 @@ body: multiple: true options: - Docker tab rendering + - Docker container actions / context menus + - Docker API / provider compatibility + - Unraid native Docker / prerelease compatibility - VM tab rendering - Dashboard widgets - Settings basic view @@ -101,6 +162,11 @@ body: - Safe mode / conflict detection - Mobile / touch behavior - Install / update detection + - Reverse proxy / request guard + - Diagnostics / support bundles + - Docker Start Order + - Plugin lifecycle / installation / updates + - Privacy / security behavior - Other validations: required: true @@ -142,6 +208,15 @@ body: validations: required: true + - type: textarea + id: relevant_settings + attributes: + label: Relevant FolderView Plus settings + description: List settings related to the problem, such as Preview style, Preview Rows, overflow behavior, context mode, nested-folder visibility, hide-empty folders, Performance Mode, view mode, sorting, or custom colors. + placeholder: "Example: Preview Rows = Unlimited; Context = Advanced; parent folder contains one child folder" + validations: + required: false + - type: textarea id: logs attributes: @@ -151,10 +226,32 @@ body: validations: required: false + - type: dropdown + id: support_bundle_status + attributes: + label: Sanitized support bundle status + description: Export from Settings -> FolderView Plus -> Advanced -> Diagnostics and review the preview before sharing. + options: + - Attached below + - Unable to export because Settings or Diagnostics will not load + - Unable to export because installation failed + - Not applicable to this report + - Need instructions + validations: + required: true + - type: textarea id: attachments attributes: label: Attachments (optional but helpful) - description: Drag and drop screenshots, recordings, export JSON, or diagnostics JSON. + description: Drag and drop screenshots, recordings, or a sanitized support bundle. Do not attach a full/unsanitized bundle or raw configuration export publicly. validations: required: false + + - type: checkboxes + id: privacy_confirmation + attributes: + label: Public attachment confirmation + options: + - label: I reviewed my attachments and removed credentials, tokens, cookies, private URLs, unsanitized diagnostics, and raw configuration exports. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 19329dd2..047fc793 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,14 @@ blank_issues_enabled: false contact_links: - - name: Documentation and Install Guide - url: https://github.com/alexphillips-dev/FolderView-Plus#readme - about: Check install, update, and usage guidance before opening an issue. + - name: FolderView Plus Wiki + url: https://github.com/alexphillips-dev/FolderView-Plus/wiki + about: Start with the complete installation, update, configuration, and usage documentation. + - name: Troubleshooting Guide + url: https://github.com/alexphillips-dev/FolderView-Plus/wiki/Troubleshooting + about: Check symptom-specific troubleshooting before opening an issue. + - name: Diagnostics and Public Sharing Safety + url: https://github.com/alexphillips-dev/FolderView-Plus/wiki/Activity-Diagnostics-and-Support + about: Learn how to export and review a sanitized support bundle for a public report. - name: FolderView Plus Support Thread (Unraid Forum) url: https://forums.unraid.net/topic/197631-plugin-folderview-plus/ about: Plugin support thread for installation, usage, and compatibility questions. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 36b83aa7..616ff44c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -49,6 +49,9 @@ body: multiple: true options: - Docker tab rendering + - Docker container actions / context menus + - Docker API / provider compatibility + - Unraid native Docker / prerelease compatibility - VM tab rendering - Dashboard widgets - Settings basic view @@ -60,8 +63,12 @@ body: - Custom icon upload/third-party icons - Safe mode / conflict detection - Mobile / touch behavior + - Docker Start Order + - Diagnostics / support bundles + - Reverse proxy / access paths + - Privacy / security behavior - Update/Release process - - Docs/README + - Docs / Wiki / README - Other validations: required: true diff --git a/.github/ISSUE_TEMPLATE/support_troubleshooting.yml b/.github/ISSUE_TEMPLATE/support_troubleshooting.yml index 6a4b2c5d..74ad986e 100644 --- a/.github/ISSUE_TEMPLATE/support_troubleshooting.yml +++ b/.github/ISSUE_TEMPLATE/support_troubleshooting.yml @@ -8,6 +8,8 @@ body: value: | Use this form for setup/help questions. For confirmed bugs, use the Bug report form instead. + This issue is public. Export a **sanitized** support bundle when possible, review its preview, and never attach credentials, tokens, cookies, private URLs, a full/unsanitized bundle, or a raw configuration export. + - type: dropdown id: help_topic attributes: @@ -21,11 +23,18 @@ body: - Import/Export behavior - Backup restore behavior - Folder ordering + - Docker or VM actions / context menus - Auto-assignment rules - Custom icon upload or icon packs - Safe mode / conflict banner - Mobile/touch layout - UI/settings behavior + - Reverse proxy or custom hostname + - Diagnostics or support-bundle export + - Docker Start Order + - Unraid compatibility + - Performance or refresh behavior + - Privacy or security behavior - Other validations: required: true @@ -46,19 +55,61 @@ body: validations: required: true + - type: dropdown + id: release_channel + attributes: + label: Installed release channel + options: + - Stable / main + - Dev testing build + - Commit-specific build + - Local or manually modified package + - Unknown + validations: + required: true + - type: dropdown id: install_source attributes: label: Install source options: - Community Applications - - plugin install (main URL) + - plugin install (stable/main URL) + - plugin install (dev URL) - plugin install (commit-specific URL) - Local file/manual - Unsure validations: required: true + - type: dropdown + id: access_method + attributes: + label: How do you access Unraid? + options: + - Direct LAN IP + - Local hostname + - Reverse proxy or custom domain + - Unraid Connect + - Tailscale or another VPN + - Other or unsure + validations: + required: true + + - type: dropdown + id: docker_view_mode + attributes: + label: Unraid Docker view mode + description: Select Not applicable when this is not a Docker-page question. + options: + - Basic View + - Advanced View + - Tested in both + - Not applicable + - Unsure + validations: + required: true + - type: input id: browser_device attributes: @@ -94,6 +145,20 @@ body: validations: required: true + - type: dropdown + id: configuration_origin + attributes: + label: Configuration origin + options: + - Fresh FolderView Plus configuration + - Upgraded from an earlier FolderView Plus version + - Imported from FolderView3 + - Imported from FolderView2 + - Restored from a backup or export + - Unsure + validations: + required: true + - type: textarea id: current_behavior attributes: @@ -119,6 +184,14 @@ body: validations: required: true + - type: textarea + id: relevant_settings + attributes: + label: Relevant FolderView Plus settings + description: List settings related to the question, such as Preview style, Preview Rows, overflow behavior, context mode, nested-folder visibility, hide-empty folders, Performance Mode, view mode, sorting, or custom colors. + validations: + required: false + - type: textarea id: logs attributes: @@ -128,11 +201,25 @@ body: validations: required: false + - type: dropdown + id: support_bundle_status + attributes: + label: Sanitized support bundle status + description: Export from Settings -> FolderView Plus -> Advanced -> Diagnostics and review the preview before sharing. + options: + - Attached below + - Unable to export because Settings or Diagnostics will not load + - Unable to export because installation failed + - Not applicable to this request + - Need instructions + validations: + required: true + - type: textarea id: attachments attributes: label: Attachments (optional but helpful) - description: Drag and drop screenshots, recordings, export JSON, or diagnostics JSON. + description: Drag and drop screenshots, recordings, or a sanitized support bundle. Do not attach a full/unsanitized bundle or raw configuration export publicly. validations: required: false @@ -141,5 +228,7 @@ body: attributes: label: Confirmation options: - - label: I reviewed the README install/update section before filing this support request. + - label: I reviewed the Wiki and troubleshooting guidance before filing this support request. + required: true + - label: I reviewed my attachments and removed credentials, tokens, cookies, private URLs, unsanitized diagnostics, and raw configuration exports. required: true diff --git a/.github/ISSUE_TEMPLATE/translation.yml b/.github/ISSUE_TEMPLATE/translation.yml index 2a9304a3..4944eba1 100644 --- a/.github/ISSUE_TEMPLATE/translation.yml +++ b/.github/ISSUE_TEMPLATE/translation.yml @@ -4,23 +4,43 @@ title: "[Translation]: " labels: - translation body: + - type: markdown + attributes: + value: | + Thank you for helping improve FolderView Plus. Check the [Translation and Accessibility Wiki page](https://github.com/alexphillips-dev/FolderView-Plus/wiki/Translation-and-Accessibility) for terminology, placeholders, review expectations, and right-to-left guidance. + - type: dropdown id: language attributes: label: Language options: - - Spanish (es) - - German (de) + - Arabic (ar) + - Bengali (bn) + - Catalan (ca) + - Chinese - Simplified (zh-Hans) + - Chinese - Traditional (zh-Hant) + - Croatian (hr) + - Czech (cs) + - Danish (da) + - Dutch (nl) - French (fr) + - German (de) + - Hungarian (hu) - Italian (it) + - Japanese (ja) + - Korean (ko) + - Latvian (lv) + - Norwegian Bokmål (nb) - Polish (pl) - - Dutch (nl) - Portuguese - Brazil (pt-BR) - Portuguese - Portugal (pt-PT) - - Chinese - Simplified (zh-Hans) - - Japanese (ja) - - Korean (ko) - - Another registered locale + - Romanian (ro) + - Russian (ru) + - Spanish (es) + - Swedish (sv) + - Turkish (tr) + - Ukrainian (uk) + - Another locale or regional variant validations: required: true - type: dropdown diff --git a/.github/SECURITY.md b/.github/SECURITY.md index b8aa365e..ec6b4b57 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -12,7 +12,8 @@ ## Reporting a Vulnerability -Please report security issues privately through GitHub Security Advisories: +Please report security issues privately through GitHub Security Advisories using +[Report a vulnerability](https://github.com/alexphillips-dev/FolderView-Plus/security/advisories/new): 1. Go to the repository `Security` tab. 2. Select `Report a vulnerability`. diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md index 8bca3255..7331360f 100644 --- a/.github/SUPPORT.md +++ b/.github/SUPPORT.md @@ -4,6 +4,9 @@ Use the right channel so issues are handled quickly. Before opening a report, check: +- [FolderView Plus Wiki](https://github.com/alexphillips-dev/FolderView-Plus/wiki) +- [Wiki Troubleshooting](https://github.com/alexphillips-dev/FolderView-Plus/wiki/Troubleshooting) +- [Wiki Activity, Diagnostics, and Support](https://github.com/alexphillips-dev/FolderView-Plus/wiki/Activity-Diagnostics-and-Support) - [User Guide](../docs/USER_GUIDE.md) - [Installation and Upgrades](../docs/INSTALLATION_AND_UPGRADES.md) - [Migration Guide](../docs/MIGRATION_GUIDE.md) @@ -20,12 +23,17 @@ Include: - Unraid version - plugin version (`/boot/config/plugins/folderview.plus/version`) +- installed release channel (stable/main, dev, commit-specific, or local/manual) +- access path (LAN IP, hostname, reverse proxy, Unraid Connect, or VPN) +- Docker Basic/Advanced View status when the Docker page is involved +- configuration origin (fresh, upgraded, imported, or restored) - clear reproduction steps - expected vs actual result +- relevant FolderView Plus settings - relevant screenshots/logs -- a sanitized support bundle from `Settings -> FolderView Plus -> Advanced -> Diagnostics` when available +- sanitized support-bundle status and, when available, the bundle from `Settings -> FolderView Plus -> Advanced -> Diagnostics` -Review the bundle preview before attaching it. Do not post an unsanitized bundle or configuration export publicly. +Review the bundle preview before attaching it. Do not post credentials, tokens, cookies, private URLs, an unsanitized bundle, or a raw configuration export publicly. If installation, Settings, or Diagnostics does not load, select that reason in the issue form instead of attaching unsafe substitute files. ## Feature Requests diff --git a/.github/workflows/backmerge-main-to-dev.yml b/.github/workflows/backmerge-main-to-dev.yml index b82b03d8..7ca21152 100644 --- a/.github/workflows/backmerge-main-to-dev.yml +++ b/.github/workflows/backmerge-main-to-dev.yml @@ -7,8 +7,7 @@ on: workflow_dispatch: permissions: - contents: write - pull-requests: write + contents: read concurrency: group: folderview-plus-backmerge-main-to-dev @@ -16,6 +15,9 @@ concurrency: jobs: backmerge: + permissions: + contents: write + pull-requests: write runs-on: ubuntu-latest timeout-minutes: 90 env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c3781c7..2edff3d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,7 @@ jobs: run: echo "seconds=$(( $(date +%s) - ${{ steps.started.outputs.epoch }} ))" >> "${GITHUB_OUTPUT}" guard-suite: + name: CI tests and guards runs-on: ubuntu-latest timeout-minutes: 30 needs: detect-changes diff --git a/.github/workflows/clone-traffic-badge.yml b/.github/workflows/clone-traffic-badge.yml index e2ac6306..bfe76f90 100644 --- a/.github/workflows/clone-traffic-badge.yml +++ b/.github/workflows/clone-traffic-badge.yml @@ -6,25 +6,21 @@ on: workflow_dispatch: permissions: - contents: write + contents: read concurrency: group: folderview-plus-clone-traffic-badge cancel-in-progress: false jobs: - refresh: + collect: if: github.repository == 'alexphillips-dev/FolderView-Plus' runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + badge_json: ${{ steps.badge.outputs.badge_json }} steps: - - name: Checkout FolderView Plus - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 1 - persist-credentials: false - - name: Build rolling clone badge data id: badge env: @@ -56,19 +52,35 @@ jobs: }; fs.writeFileSync(badgePath, `${JSON.stringify(badge)}\n`, 'utf8'); NODE - echo "badge_path=${badge_path}" >> "${GITHUB_OUTPUT}" + echo "badge_json=$(cat "${badge_path}")" >> "${GITHUB_OUTPUT}" + + publish: + if: github.repository == 'alexphillips-dev/FolderView-Plus' + needs: collect + permissions: + contents: write + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: - name: Publish rolling clone badge data env: GH_TOKEN: ${{ github.token }} - BADGE_PATH: ${{ steps.badge.outputs.badge_path }} + BADGE_JSON: ${{ needs.collect.outputs.badge_json }} shell: bash run: | set -euo pipefail metrics_dir="${RUNNER_TEMP}/folderview-plus-metrics" + badge_path="${RUNNER_TEMP}/clone-traffic.json" + if [[ -z "${BADGE_JSON:-}" ]]; then + echo "::error title=Clone badge data missing::The isolated collection job returned no badge data." + exit 1 + fi + printf '%s\n' "${BADGE_JSON}" > "${badge_path}" gh auth setup-git git clone --depth 1 --branch metrics "https://github.com/${GITHUB_REPOSITORY}.git" "${metrics_dir}" - cp "${BADGE_PATH}" "${metrics_dir}/clone-traffic.json" + cp "${badge_path}" "${metrics_dir}/clone-traffic.json" cd "${metrics_dir}" if git diff --quiet -- clone-traffic.json; then echo "Rolling clone count is unchanged." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ca44d1b6..7ded1e01 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -16,7 +16,6 @@ on: permissions: actions: read contents: read - security-events: write concurrency: group: folderview-plus-codeql-${{ github.ref }} @@ -25,6 +24,10 @@ concurrency: jobs: analyze: name: Analyze (JavaScript) + permissions: + actions: read + contents: read + security-events: write runs-on: ubuntu-latest timeout-minutes: 30 diff --git a/.github/workflows/release-on-main.yml b/.github/workflows/release-on-main.yml index e3bdc519..9ebb87ab 100644 --- a/.github/workflows/release-on-main.yml +++ b/.github/workflows/release-on-main.yml @@ -7,9 +7,7 @@ on: workflow_dispatch: permissions: - contents: write - id-token: write - attestations: write + contents: read concurrency: group: folderview-plus-release @@ -17,6 +15,10 @@ concurrency: jobs: release: + permissions: + contents: write + id-token: write + attestations: write runs-on: ubuntu-latest timeout-minutes: 90 diff --git a/README.md b/README.md index c5dee1f2..a3f55ba4 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,14 @@ Unraid's Docker and VM pages can become difficult to scan as your server grows. | Use the Activity Center, Settings diagnostics, runtime banners, folder editor bootstrap diagnostics, and sanitized support bundles to understand what happened and share useful reports. | Uses shared dark/light theme tokens, modernized Settings and editor surfaces, runtime-safe menu styling, and compatibility guards for Unraid themes and legacy installs. | | Activity Center | Dark mode UI | +### Custom WebUI profiles + +Docker folders can keep multiple named WebUI launch profiles (up to 100 per folder), such as `Media Admin`, `Monitoring`, or `Daily Tools`. Each profile selects direct members of that folder; launching it opens only selected containers that are currently running and have a safe WebUI available. The original **Open all WebUIs** action remains available. + +Create and maintain profiles from the folder editor's **WebUI Profiles** section, then launch them from the folder menu under **Open WebUI profile**. Profiles are included with folder clone, export, import, backup, and restore operations, but are intentionally excluded from generic settings copy/paste, templates, and saved defaults because their member selections belong to one specific folder. + +> Screenshot placeholder: WebUI Profiles editor and Docker folder profile submenu. + ## Install Install from Unraid: diff --git a/archive/folderview.plus-2026.08.12.02.txz b/archive/folderview.plus-2026.08.12.02.txz deleted file mode 100644 index 779d2f35..00000000 Binary files a/archive/folderview.plus-2026.08.12.02.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.12.02.txz.sha256 b/archive/folderview.plus-2026.08.12.02.txz.sha256 deleted file mode 100644 index 640879f3..00000000 --- a/archive/folderview.plus-2026.08.12.02.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -f87ce77ad94ca0c320b74ce37ddfad46d1c3b956accaab260fa5b52c0b83696c folderview.plus-2026.08.12.02.txz diff --git a/archive/folderview.plus-2026.08.12.03.txz b/archive/folderview.plus-2026.08.12.03.txz deleted file mode 100644 index fc859033..00000000 Binary files a/archive/folderview.plus-2026.08.12.03.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.12.03.txz.sha256 b/archive/folderview.plus-2026.08.12.03.txz.sha256 deleted file mode 100644 index 5ee374e8..00000000 --- a/archive/folderview.plus-2026.08.12.03.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -67b7eb711c29b0ab902701106e535f9abc7743f1acd9a4c56d778c525d313f09 folderview.plus-2026.08.12.03.txz diff --git a/archive/folderview.plus-2026.08.12.04.txz b/archive/folderview.plus-2026.08.12.04.txz deleted file mode 100644 index 7050cf56..00000000 Binary files a/archive/folderview.plus-2026.08.12.04.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.12.04.txz.sha256 b/archive/folderview.plus-2026.08.12.04.txz.sha256 deleted file mode 100644 index 0f145f5e..00000000 --- a/archive/folderview.plus-2026.08.12.04.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -3f6f3af6655e2587b59badffcaa6b7211f4f5790ae760fa12d53fa6e98685d8a folderview.plus-2026.08.12.04.txz diff --git a/archive/folderview.plus-2026.08.12.05.txz b/archive/folderview.plus-2026.08.12.05.txz deleted file mode 100644 index fa058098..00000000 Binary files a/archive/folderview.plus-2026.08.12.05.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.12.05.txz.sha256 b/archive/folderview.plus-2026.08.12.05.txz.sha256 deleted file mode 100644 index 5a5d9838..00000000 --- a/archive/folderview.plus-2026.08.12.05.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -4ad7e0839e54e948181bd7d415a7df5bdf51fd11663ac533756ac71287065c79 folderview.plus-2026.08.12.05.txz diff --git a/archive/folderview.plus-2026.08.12.06.txz b/archive/folderview.plus-2026.08.12.06.txz deleted file mode 100644 index 161ec70a..00000000 Binary files a/archive/folderview.plus-2026.08.12.06.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.12.06.txz.sha256 b/archive/folderview.plus-2026.08.12.06.txz.sha256 deleted file mode 100644 index cfb04e77..00000000 --- a/archive/folderview.plus-2026.08.12.06.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -6cc76433f0dd221c9fc2af4667bbffc0e8a30c0544ce01b5afd44194f0ff73c4 folderview.plus-2026.08.12.06.txz diff --git a/archive/folderview.plus-2026.08.12.07.txz b/archive/folderview.plus-2026.08.12.07.txz deleted file mode 100644 index 944f5032..00000000 Binary files a/archive/folderview.plus-2026.08.12.07.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.12.07.txz.sha256 b/archive/folderview.plus-2026.08.12.07.txz.sha256 deleted file mode 100644 index 596ab8ca..00000000 --- a/archive/folderview.plus-2026.08.12.07.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -1ff4f2820ca614b560d7ac1a3738f2eef2f5f87098508118bd43828a11bae58a folderview.plus-2026.08.12.07.txz diff --git a/archive/folderview.plus-2026.08.17.01.txz b/archive/folderview.plus-2026.08.17.01.txz deleted file mode 100644 index 3d55d969..00000000 Binary files a/archive/folderview.plus-2026.08.17.01.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.17.01.txz.sha256 b/archive/folderview.plus-2026.08.17.01.txz.sha256 deleted file mode 100644 index 1ce70b81..00000000 --- a/archive/folderview.plus-2026.08.17.01.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -693b647ab14c2f8389defe3b9ec348b611ca8e203df2a4057f6e75b1fc86a12d folderview.plus-2026.08.17.01.txz diff --git a/archive/folderview.plus-2026.08.18.02.txz b/archive/folderview.plus-2026.08.18.02.txz deleted file mode 100644 index cdf86fe8..00000000 Binary files a/archive/folderview.plus-2026.08.18.02.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.18.02.txz.sha256 b/archive/folderview.plus-2026.08.18.02.txz.sha256 deleted file mode 100644 index 621efc9d..00000000 --- a/archive/folderview.plus-2026.08.18.02.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -b7219d6df88f40c42353417e59c7512895ff9851a0d6fdc758006ca8cf703537 folderview.plus-2026.08.18.02.txz diff --git a/archive/folderview.plus-2026.08.18.03.txz b/archive/folderview.plus-2026.08.18.03.txz deleted file mode 100644 index ed7ca5e1..00000000 Binary files a/archive/folderview.plus-2026.08.18.03.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.18.03.txz.sha256 b/archive/folderview.plus-2026.08.18.03.txz.sha256 deleted file mode 100644 index c0507679..00000000 --- a/archive/folderview.plus-2026.08.18.03.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -f09dbae6c0a2f31260872171dfba1d06248a7c2ce0acfd8041c33667de26087f folderview.plus-2026.08.18.03.txz diff --git a/archive/folderview.plus-2026.08.18.04.txz b/archive/folderview.plus-2026.08.18.04.txz deleted file mode 100644 index 754a543e..00000000 Binary files a/archive/folderview.plus-2026.08.18.04.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.18.04.txz.sha256 b/archive/folderview.plus-2026.08.18.04.txz.sha256 deleted file mode 100644 index b0e2bb35..00000000 --- a/archive/folderview.plus-2026.08.18.04.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -0ef5f9830453c1a4ed8563c453c4122b134cf51bdbbc6880303910759d58e433 folderview.plus-2026.08.18.04.txz diff --git a/archive/folderview.plus-2026.08.24.04.txz b/archive/folderview.plus-2026.08.24.04.txz deleted file mode 100644 index 022cd5ca..00000000 Binary files a/archive/folderview.plus-2026.08.24.04.txz and /dev/null differ diff --git a/archive/folderview.plus-2026.08.24.04.txz.sha256 b/archive/folderview.plus-2026.08.24.04.txz.sha256 deleted file mode 100644 index aad7e493..00000000 --- a/archive/folderview.plus-2026.08.24.04.txz.sha256 +++ /dev/null @@ -1 +0,0 @@ -9f4eca819eddf4f5a501137f13b743a259a52fff7a35d6e74278b797641ff259 folderview.plus-2026.08.24.04.txz diff --git a/archive/folderview.plus-2026.08.25.01.txz b/archive/folderview.plus-2026.08.25.01.txz new file mode 100644 index 00000000..6dd425f6 Binary files /dev/null and b/archive/folderview.plus-2026.08.25.01.txz differ diff --git a/archive/folderview.plus-2026.08.25.01.txz.sha256 b/archive/folderview.plus-2026.08.25.01.txz.sha256 new file mode 100644 index 00000000..d4a96673 --- /dev/null +++ b/archive/folderview.plus-2026.08.25.01.txz.sha256 @@ -0,0 +1 @@ +7dfd5e03ee5f8624f75c3174cea43b9572ce4cb308cc7a6b25dc4762fa4fcd20 folderview.plus-2026.08.25.01.txz diff --git a/archive/folderview.plus-2026.08.27.01.txz b/archive/folderview.plus-2026.08.27.01.txz new file mode 100644 index 00000000..84a4186b Binary files /dev/null and b/archive/folderview.plus-2026.08.27.01.txz differ diff --git a/archive/folderview.plus-2026.08.27.01.txz.sha256 b/archive/folderview.plus-2026.08.27.01.txz.sha256 new file mode 100644 index 00000000..610ddc2c --- /dev/null +++ b/archive/folderview.plus-2026.08.27.01.txz.sha256 @@ -0,0 +1 @@ +500d264b9e8d2e0a2121296be776c27832197bd414bd6dd600cb2d0cddb15b65 folderview.plus-2026.08.27.01.txz diff --git a/archive/folderview.plus-2026.08.27.02.txz b/archive/folderview.plus-2026.08.27.02.txz new file mode 100644 index 00000000..5b476c4e Binary files /dev/null and b/archive/folderview.plus-2026.08.27.02.txz differ diff --git a/archive/folderview.plus-2026.08.27.02.txz.sha256 b/archive/folderview.plus-2026.08.27.02.txz.sha256 new file mode 100644 index 00000000..2a3d81f0 --- /dev/null +++ b/archive/folderview.plus-2026.08.27.02.txz.sha256 @@ -0,0 +1 @@ +6216f87629e3f83e211cd2b795c72fd36d60271bbc5208ba5d602626651768bf folderview.plus-2026.08.27.02.txz diff --git a/archive/folderview.plus-2026.08.27.03.txz b/archive/folderview.plus-2026.08.27.03.txz new file mode 100644 index 00000000..ae3ed23a Binary files /dev/null and b/archive/folderview.plus-2026.08.27.03.txz differ diff --git a/archive/folderview.plus-2026.08.27.03.txz.sha256 b/archive/folderview.plus-2026.08.27.03.txz.sha256 new file mode 100644 index 00000000..68c2a2f3 --- /dev/null +++ b/archive/folderview.plus-2026.08.27.03.txz.sha256 @@ -0,0 +1 @@ +e5016eb8b88748c8f45540564b35a54af49c559c6ad9d038ec14e389e52de7d0 folderview.plus-2026.08.27.03.txz diff --git a/archive/folderview.plus-2026.08.27.04.txz b/archive/folderview.plus-2026.08.27.04.txz new file mode 100644 index 00000000..d16ab8b7 Binary files /dev/null and b/archive/folderview.plus-2026.08.27.04.txz differ diff --git a/archive/folderview.plus-2026.08.27.04.txz.sha256 b/archive/folderview.plus-2026.08.27.04.txz.sha256 new file mode 100644 index 00000000..0454e830 --- /dev/null +++ b/archive/folderview.plus-2026.08.27.04.txz.sha256 @@ -0,0 +1 @@ +ae96ef201042e51fcdecfd3b05cb49215cd1a5b523f3a70c5bf3411760c31af9 folderview.plus-2026.08.27.04.txz diff --git a/archive/folderview.plus-2026.08.27.05.txz b/archive/folderview.plus-2026.08.27.05.txz new file mode 100644 index 00000000..8ba18375 Binary files /dev/null and b/archive/folderview.plus-2026.08.27.05.txz differ diff --git a/archive/folderview.plus-2026.08.27.05.txz.sha256 b/archive/folderview.plus-2026.08.27.05.txz.sha256 new file mode 100644 index 00000000..20d64043 --- /dev/null +++ b/archive/folderview.plus-2026.08.27.05.txz.sha256 @@ -0,0 +1 @@ +5fba9f842534e82d8fc9e7cf69d68d67e737a2448fbf6e8760994dc05f07fca2 folderview.plus-2026.08.27.05.txz diff --git a/archive/folderview.plus-2026.08.28.01.txz b/archive/folderview.plus-2026.08.28.01.txz new file mode 100644 index 00000000..2df2bccb Binary files /dev/null and b/archive/folderview.plus-2026.08.28.01.txz differ diff --git a/archive/folderview.plus-2026.08.28.01.txz.sha256 b/archive/folderview.plus-2026.08.28.01.txz.sha256 new file mode 100644 index 00000000..1b382c68 --- /dev/null +++ b/archive/folderview.plus-2026.08.28.01.txz.sha256 @@ -0,0 +1 @@ +e3ed087328f3bec0d5d34d64660eed99359f2cb28c925718d052d5f4084febca folderview.plus-2026.08.28.01.txz diff --git a/archive/folderview.plus-2026.08.28.02.txz b/archive/folderview.plus-2026.08.28.02.txz new file mode 100644 index 00000000..6107753e Binary files /dev/null and b/archive/folderview.plus-2026.08.28.02.txz differ diff --git a/archive/folderview.plus-2026.08.28.02.txz.sha256 b/archive/folderview.plus-2026.08.28.02.txz.sha256 new file mode 100644 index 00000000..a55e5616 --- /dev/null +++ b/archive/folderview.plus-2026.08.28.02.txz.sha256 @@ -0,0 +1 @@ +451ee2fa011a7f5c867c9b8f46bc9d5e6783795a637ba72e61e0f0e75fd299d4 folderview.plus-2026.08.28.02.txz diff --git a/archive/folderview.plus-2026.08.28.03.txz b/archive/folderview.plus-2026.08.28.03.txz new file mode 100644 index 00000000..052db11e Binary files /dev/null and b/archive/folderview.plus-2026.08.28.03.txz differ diff --git a/archive/folderview.plus-2026.08.28.03.txz.sha256 b/archive/folderview.plus-2026.08.28.03.txz.sha256 new file mode 100644 index 00000000..cf81a9e1 --- /dev/null +++ b/archive/folderview.plus-2026.08.28.03.txz.sha256 @@ -0,0 +1 @@ +255a697535c6b14f95631fa836889a0ddda0c11c388ee2bd313ed21d779e0093 folderview.plus-2026.08.28.03.txz diff --git a/archive/folderview.plus-2026.08.28.04.txz b/archive/folderview.plus-2026.08.28.04.txz new file mode 100644 index 00000000..0782d3ba Binary files /dev/null and b/archive/folderview.plus-2026.08.28.04.txz differ diff --git a/archive/folderview.plus-2026.08.28.04.txz.sha256 b/archive/folderview.plus-2026.08.28.04.txz.sha256 new file mode 100644 index 00000000..e7ebd488 --- /dev/null +++ b/archive/folderview.plus-2026.08.28.04.txz.sha256 @@ -0,0 +1 @@ +4c9da7f4379224d2b65ff9957c08ab5e996d6c8d7082762504f7415ef1f95194 folderview.plus-2026.08.28.04.txz diff --git a/archive/folderview.plus-2026.08.28.05.txz b/archive/folderview.plus-2026.08.28.05.txz new file mode 100644 index 00000000..3a2cd91f Binary files /dev/null and b/archive/folderview.plus-2026.08.28.05.txz differ diff --git a/archive/folderview.plus-2026.08.28.05.txz.sha256 b/archive/folderview.plus-2026.08.28.05.txz.sha256 new file mode 100644 index 00000000..952d2ef3 --- /dev/null +++ b/archive/folderview.plus-2026.08.28.05.txz.sha256 @@ -0,0 +1 @@ +f71ee236840caadc58c6e22630a311670fa39d39f48293d086182cf0bb4dae88 folderview.plus-2026.08.28.05.txz diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 4d6212f5..2552eb41 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -61,15 +61,39 @@ The Docker page View menu provides three supported modes: Changing views does not delete folder configuration. Use `Reset view` to clear temporary toolbar filters and return to the normal unfiltered state. +Each Docker folder menu includes four quick actions: focus, pin, lock, and hide. Hiding a parent hides its complete nested branch and member rows from FolderView without deleting configuration or changing assignments. Use the temporary `Undo` notice immediately after hiding, or open `View -> Hidden folders` to reveal hidden rows. Revealed rows are dimmed and labeled `Hidden`; restore one from its folder menu or choose `Restore all hidden folders`. Hidden folders remain visible in Host list mode because that mode intentionally restores Unraid's native table. + VM and Dashboard surfaces use the same saved Docker or VM folder configuration where applicable. Runtime state is reconciled incrementally so start, stop, pause, resume, and update results can update without rebuilding the entire page. ## Folder actions +### Custom WebUI profiles for Docker folders + +Use a custom WebUI profile when you regularly open only part of a Docker folder. Profiles do not store URLs. They store selected direct-member names and resolve each container's current safe WebUI address and running state at launch time. + +To create a profile: + +1. Edit a Docker folder and open **WebUI Profiles**. +2. Select **Add profile** and enter a unique name. +3. Select one or more direct folder members. Search, **Select all with WebUI**, and **Clear selection** are available for larger folders. +4. Save the folder. Empty profiles and duplicate names are rejected. +5. Open the folder menu on the Docker page, choose **Open WebUI profile**, and select the profile. Its ready count shows how many selected WebUIs can open now. + +Only selected containers that are running, not paused, and expose a safe WebUI are opened. Stopped, paused, removed, or currently unavailable members remain selected for later but are skipped. If a browser blocks one or more tabs, allow popups for the Unraid host and retry the same profile. The original **Open all WebUIs** action still opens every ready WebUI in that folder. + +Use **Manage WebUI profiles** in the folder menu to return directly to the editor section. Profiles can be duplicated, reordered, or deleted. Each folder supports up to 100 profiles with up to 250 selected members per profile. Container renames are reconciled automatically when FolderView Plus can uniquely identify the renamed container; unresolved members are labeled unavailable so the selection can be corrected manually. + +Profiles follow a full folder clone and are preserved by folder export/import and backup/restore. Generic **Copy Folder Settings**, reusable templates, smart defaults, and saved folder defaults omit them so container-specific selections are not applied to unrelated folders. + +> Screenshot placeholder: WebUI Profiles editor showing named profiles and selected members. + +> Screenshot placeholder: Docker folder menu showing Open all WebUIs, Open WebUI profile, ready counts, and Manage WebUI profiles. + The folder action sheet groups actions by purpose rather than placing every operation in one flat menu. Available actions depend on folder type, hierarchy position, lock state, and current member state. Common actions include: -- Edit, pin, focus, lock, expand, or collapse a folder or branch. +- Edit, pin, focus, lock, hide, expand, or collapse a folder or branch. - Start, stop, pause, resume, restart, or update eligible members. - Move a folder within its current level, under another folder, or back to the root. - Clone, export, import into, scan, repair, or delete a folder branch. diff --git a/docs/docker-runtime-architecture.md b/docs/docker-runtime-architecture.md index 778b1a4f..f3731c0b 100644 --- a/docs/docker-runtime-architecture.md +++ b/docs/docker-runtime-architecture.md @@ -87,7 +87,7 @@ The runtime still uses an internal threshold state while resolving Adaptive and - Bootstrap repeats host detection after parsing, then enables legacy styles, loads legacy custom overrides in sequence, and finally loads `docker.js` only for a confirmed complete table contract. - Native and unknown generations keep every legacy stylesheet inert and return before custom overrides, adapters, hooks, requests, observers, or jQuery prefilters are created. - Unraid lifecycle globals (`loadlist`, `listview`, `openDocker`, `eventControl`, and `addDockerContainerContext`) are wrapped idempotently by the adapter, with compatibility aliases retained for host/plugin interoperability. -- Context menu quick actions (Focus/Pin/Lock) are enhanced through the adapter rather than ad-hoc DOM logic. +- Context menu quick actions (Focus/Pin/Lock/Hide) are enhanced through the adapter rather than ad-hoc DOM logic. Hidden folder IDs persist in normalized Docker preferences, parent visibility applies to the complete branch, and the View menu provides temporary reveal and restore-all recovery paths. - CSS layout constants use tokenized variables with hard-coded fallback values to preserve legacy contracts. - Preview hydration diagnostics record unavailable WebUI slots, disconnected targets, absolute movement, row movement, and movement relative to the owning row without retaining container or folder identities. - The main Docker runtime line budget is ratcheted from 8,839 to 8,028 lines. Column layout/control moved behind a lifecycle-owned factory, while reusable ordering and live-refresh seams reduced duplicate ownership across Docker, VM, and Dashboard. diff --git a/docs/releases/2026.08.25.01.md b/docs/releases/2026.08.25.01.md new file mode 100644 index 00000000..8d611972 --- /dev/null +++ b/docs/releases/2026.08.25.01.md @@ -0,0 +1,5 @@ +- Fix: Open the same child-folder action menu from a collapsed parent preview when using a normal click, right-click, touch activation, Enter, or Space. +- Accessibility: Position keyboard-opened menus beside the child-folder preview chip, focus the first menu action, and retain the existing Expand, Edit, and Open folder actions. +- Diagnostics: Add bounded child-folder preview render, binding, menu-attempt, success, failure, and input-method counters to sanitized support bundles. +- Privacy: Keep child-folder preview diagnostics free of folder names, identifiers, selectors, URLs, and pointer coordinates. +- Test: Cover child-folder menu activation after repeated multi-row preview reconciliation and verify mouse, keyboard, context-menu, touch, focus, geometry, and diagnostic behavior in the browser fixture suite. diff --git a/docs/releases/2026.08.27.01.md b/docs/releases/2026.08.27.01.md new file mode 100644 index 00000000..919238f7 --- /dev/null +++ b/docs/releases/2026.08.27.01.md @@ -0,0 +1,6 @@ +- Fix: Use green as the default started-status color in the folder editor and across Docker, VM, and Dashboard folder surfaces. +- Compatibility: Migrate the previous unmodified white started-status default to green while preserving white when it is explicitly selected and saved in the modern folder editor. +- Fix: Show a visible pending spinner on compact multi-row Docker previews while a container lifecycle action, including Restart, is in progress. +- Fix: Keep Restart pending through the initial already-running snapshot, then restore the container preview to its current started, paused, or stopped state after reconciliation. +- Diagnostics: Record privacy-safe counts for lifecycle preview surfaces and pending or restored status icons without exporting container identities. +- Test: Cover legacy and explicit status-color behavior, compact-preview lifecycle feedback, restart timing, browser fixtures, responsive themes, and large-runtime performance budgets. diff --git a/docs/releases/2026.08.27.02.md b/docs/releases/2026.08.27.02.md new file mode 100644 index 00000000..c0de9457 --- /dev/null +++ b/docs/releases/2026.08.27.02.md @@ -0,0 +1,6 @@ +- Fix: Restore native-style folder status presentation with green started indicators and independently white status text by default. +- Feature: Add a Text color picker to the modern folder editor Status section so status labels can be customized separately from Started, Paused, and Stopped indicators. +- Compatibility: Apply the independent status text color across Docker folders and previews, VM folders, Dashboard folders, inherited defaults, starter templates, and existing folders without requiring migration. +- Diagnostics: Include the normalized status text color in privacy-safe folder integrity diagnostics. +- Localization: Translate the new status text control and updated guidance across all 27 supported locales. +- Test: Cover status text normalization, editor persistence, runtime theme separation, localization, browser fixtures, responsive themes, and large-runtime performance budgets. diff --git a/docs/releases/2026.08.27.03.md b/docs/releases/2026.08.27.03.md new file mode 100644 index 00000000..9a2ba3dc --- /dev/null +++ b/docs/releases/2026.08.27.03.md @@ -0,0 +1,4 @@ +- Fix: Show the same pending lifecycle spinner for Docker containers in single-row and multi-row folder previews. +- Fix: Keep Start, Resume, and Restart feedback on the folder's Started color instead of temporarily using the Paused color; Pause and Stop continue to use their matching status colors. +- Compatibility: Honor each folder's customized Started, Paused, and Stopped indicator colors while lifecycle actions are pending. +- Test: Cover single-row lifecycle identity, action-specific pending colors, spinner cleanup, browser fixtures, responsive themes, and large-runtime performance budgets. diff --git a/docs/releases/2026.08.27.04.md b/docs/releases/2026.08.27.04.md new file mode 100644 index 00000000..60532ae2 --- /dev/null +++ b/docs/releases/2026.08.27.04.md @@ -0,0 +1,4 @@ +- Fix: Restore container context actions in collapsed parent-folder previews when the folder contains child folders. +- Compatibility: Apply the correct Default or Advanced context behavior after nested previews are rebuilt, including Preview Rows 1+, Unlimited, and Unraid Basic or Advanced View. +- Compatibility: Preserve the parent folder's popup settings and keep child-folder preview actions independent. +- Test: Cover nested parent container actions in both context modes with Unraid Advanced View enabled, plus child-folder actions, browser fixtures, and large-runtime performance budgets. diff --git a/docs/releases/2026.08.27.05.md b/docs/releases/2026.08.27.05.md new file mode 100644 index 00000000..1b0148f0 --- /dev/null +++ b/docs/releases/2026.08.27.05.md @@ -0,0 +1,7 @@ +- Feature: Add a fourth **Hide** quick action to Docker folder menus. Hiding a parent folder temporarily removes its complete nested branch from FolderView without deleting folders, assignments, rules, or settings. +- UX: Add a hidden-folder count, temporary **Show hidden folders** view, **Restore all hidden folders** action, and a 10-second **Undo** notice so hidden rows always remain recoverable. +- UX: Dim temporarily revealed folders and mark them as hidden while leaving Unraid's native Host list unchanged. +- Reliability: Persist hidden-folder choices through the existing conflict-safe preference flow, preserve the newest choice during rapid or cross-tab updates, clear focus when its folder is hidden, and prune references to deleted folders. +- Diagnostics: Add privacy-safe hidden-folder counts, hashed identifiers, integrity checks, and effective visibility state to runtime diagnostics and support bundles. +- Localization: Add the hidden-folder interface text to all 27 supported locale catalogs. +- Test: Cover nested branch hiding, reveal, restore-all, undo, preference conflicts, diagnostics, accessibility, browser rendering, themes, and runtime performance. diff --git a/docs/releases/2026.08.28.01.md b/docs/releases/2026.08.28.01.md new file mode 100644 index 00000000..87853ce6 --- /dev/null +++ b/docs/releases/2026.08.28.01.md @@ -0,0 +1,4 @@ +- Fix: Show the 10-second Docker folder **Undo** notice immediately when a folder is hidden instead of waiting for preference persistence to finish. +- Reliability: Keep the optimistic hide and Undo actions conflict-safe, dismiss only the matching notice during restoration, and remove a failed hide notice before rolling the folder state back. +- UI/UX: Style Undo, Show hidden folders, and Restore all hidden folders with scoped theme tokens so Unraid host button rules cannot force an orange outline. +- Test: Cover deferred preference saves, save failure rollback, folder-aware notice dismissal, and 552 browser combinations across light and dark themes, desktop and mobile viewports, and Chromium, Firefox, and WebKit. diff --git a/docs/releases/2026.08.28.02.md b/docs/releases/2026.08.28.02.md new file mode 100644 index 00000000..f7139d37 --- /dev/null +++ b/docs/releases/2026.08.28.02.md @@ -0,0 +1,8 @@ +- Feature: Add reusable custom WebUI profiles to each Docker folder so one menu action can open only the selected container WebUIs. +- UI/UX: Add profile creation, naming, duplication, ordering, search, WebUI-aware selection, unavailable-member visibility, and deletion to the modern Docker folder editor, with responsive keyboard-accessible controls. +- Reliability: Resolve each selected container's current WebUI only when a profile is launched, open direct running and unpaused members only, retain renamed members through identity reconciliation, and fail closed when a profile no longer exists. +- Compatibility: Preserve folder-specific profiles through clone, full export/import, backup, and restore while excluding them from generic settings copy/paste, templates, and saved defaults that may target different containers. +- Privacy: Add support-bundle profile health counts without exporting profile names, container names, or WebUI addresses. +- Localization: Translate the custom WebUI profile editor and Docker menu across every supported locale. +- Docs: Document profile setup, launch behavior, popup handling, migration behavior, and current screenshot placeholders in the README and user guide. +- Test: Cover browser, mobile, keyboard, theme, persistence, launch eligibility, popup safety, rename, transfer, diagnostics, localization, and large-library performance behavior using isolated fixtures. diff --git a/docs/releases/2026.08.28.03.md b/docs/releases/2026.08.28.03.md new file mode 100644 index 00000000..109763ac --- /dev/null +++ b/docs/releases/2026.08.28.03.md @@ -0,0 +1,3 @@ +- UI/UX: Style every custom WebUI profile editor action with FolderView Plus controls instead of Unraid's default orange button treatment. +- Accessibility: Preserve visible keyboard focus and use the plugin's theme-aware text, surface, hover, active, disabled, and shadow tokens across dark and light themes. +- Test: Verify custom WebUI profile buttons override hostile host defaults and follow the final dark and light theme palettes in an isolated browser fixture. diff --git a/docs/releases/2026.08.28.04.md b/docs/releases/2026.08.28.04.md new file mode 100644 index 00000000..497ce29a --- /dev/null +++ b/docs/releases/2026.08.28.04.md @@ -0,0 +1,4 @@ +- Fix: Keep hidden-folder restore labels and custom WebUI profile warnings registered as active localized UI text. +- Reliability: Prevent strict localization validation and main-to-dev back-merges from rejecting those live Docker translation keys. +- Security: Reject malformed non-string folder names and container identifiers without invoking attacker-controlled coercion hooks. +- Test: Rebuild all 27 locale catalogs and verify hidden-folder behavior, localization coverage, and security workflow contracts. diff --git a/docs/releases/2026.08.28.05.md b/docs/releases/2026.08.28.05.md new file mode 100644 index 00000000..cbb8dcad --- /dev/null +++ b/docs/releases/2026.08.28.05.md @@ -0,0 +1,15 @@ +- Feature: Add a fourth **Hide** quick action to Docker folder menus, including nested-branch hiding, a temporary **Show hidden folders** view, **Restore all hidden folders**, and an immediate 10-second **Undo** action without deleting folder configuration or changing Unraid's native Host list. +- Feature: Add reusable custom WebUI profiles to each Docker folder so any number of named profiles can open only the selected running, unpaused container WebUIs, with create, duplicate, reorder, search, unavailable-member, and delete controls in the modern folder editor. +- Fix: Restore reliable container and child-folder context menus across single-row, multi-row, nested, collapsed, Preview Rows 1+, Unlimited, and Unraid Basic or Advanced View layouts, including mouse, right-click, touch, Enter, and Space activation. +- Fix: Show consistent lifecycle progress on single-row and multi-row Docker previews, keep Restart pending through its initial running snapshot, and use the action's destination status color until reconciliation completes. +- UI/UX: Use Unraid's native green started indicator with white status text by default, add a separate **Text** color picker, and apply customized Started, Paused, Stopped, and Text colors consistently across Docker, VM, Dashboard, inherited defaults, and starter templates. +- UI/UX: Make hidden-folder recovery, Undo, and custom WebUI profile controls theme-aware, responsive, and keyboard accessible instead of inheriting Unraid's orange button treatment. +- Reliability: Persist hidden-folder choices through conflict-safe preference updates, preserve profiles through clone, export/import, backup, restore, and unique member renames, and keep folder-specific profiles out of generic settings transfers and defaults that may target unrelated containers. +- Compatibility: Preserve explicitly selected white started indicators while migrating only the old unmodified white default to green, and acknowledge Unraid API v4.37.3 while keeping current legacy Docker behavior and future API capability monitoring bounded and fail-closed. +- Security: Reject malformed non-string folder names and container identifiers without invoking attacker-controlled coercion hooks, strengthen property-based input coverage, and tighten repository security-policy, CodeQL, workflow-permission, package-trust, and release contracts. +- Privacy: Extend sanitized diagnostics with aggregate context-menu, lifecycle, hidden-folder, and WebUI-profile health evidence while withholding folder names, container names, profile names, URLs, selectors, coordinates, and other runtime identities. +- Localization: Translate the new hidden-folder, WebUI-profile, and status-text interfaces across all 27 supported locales and keep dynamic Docker menu labels covered by strict localization validation. +- UX: Improve GitHub bug, support, feature, and translation intake forms with clearer reproduction, version, branch, diagnostics, privacy, compatibility, and testing fields. +- Maintenance: Add proactive Unraid/API compatibility checks, dependency and SBOM vulnerability monitoring, scheduled workflow watchdogs, manual CodeQL recovery checks, exact-tool CodeQL alert filtering, and validated main-to-dev back-merge ordering without introducing live-Unraid automation. +- Compatibility: No manual migration is required. Existing folders, assignments, rules, colors, backups, and imports remain compatible; hidden-folder state is reversible, and custom WebUI profiles remain scoped to their Docker folder. +- Test: Expand isolated browser, mobile, keyboard, theme, localization, fuzz, security, diagnostics, workflow, and large-library performance coverage for every behavior in this release. diff --git a/docs/sbom.cdx.json b/docs/sbom.cdx.json index 723bcb79..6a2f0fd5 100644 --- a/docs/sbom.cdx.json +++ b/docs/sbom.cdx.json @@ -1,13 +1,13 @@ { "bomFormat": "CycloneDX", "specVersion": "1.5", - "serialNumber": "urn:uuid:8d6ef646-c87b-8601-8d30-57908769da9d", + "serialNumber": "urn:uuid:e1de95ce-ed49-8928-999c-a260ac5c59c3", "version": 1, "metadata": { "component": { "type": "application", "name": "FolderView Plus", - "version": "2026.08.24.04", + "version": "2026.08.28.05", "properties": [ { "name": "folderview-plus:runtime-dependencies", @@ -569,6 +569,20 @@ ], "purl": "pkg:npm/escalade@3.2.0" }, + { + "type": "library", + "name": "fast-check", + "version": "4.9.0", + "scope": "optional", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/fast-check@4.9.0" + }, { "type": "library", "name": "find-up", @@ -1099,6 +1113,20 @@ ], "purl": "pkg:npm/playwright@1.62.1" }, + { + "type": "library", + "name": "pure-rand", + "version": "8.4.2", + "scope": "optional", + "licenses": [ + { + "license": { + "id": "MIT" + } + } + ], + "purl": "pkg:npm/pure-rand@8.4.2" + }, { "type": "library", "name": "semver", diff --git a/docs/security/csp-readiness.json b/docs/security/csp-readiness.json index f93b851e..0c690a25 100644 --- a/docs/security/csp-readiness.json +++ b/docs/security/csp-readiness.json @@ -3,7 +3,7 @@ "mode": "report-only-shared-unraid-document", "scope": { "scannedRoot": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus", - "scannedFiles": 234, + "scannedFiles": 238, "exclusions": [ "third-party icon asset pack", "vendored scripts", @@ -21,7 +21,7 @@ "totals": { "inlineEventAttributes": 0, "inlineScriptBlocks": 0, - "externalScriptTags": 205, + "externalScriptTags": 211, "inlineStyleBlocks": 0, "inlineStyleAttributes": 0, "evalCalls": 0, @@ -72,7 +72,7 @@ "htmlStringSinkInventory": [ { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js", - "line": 1238, + "line": 1257, "sink": ".innerHTML =", "risk": "high", "dataClass": "persisted-or-runtime-data", @@ -82,7 +82,7 @@ }, { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js", - "line": 1249, + "line": 1268, "sink": ".innerHTML =", "risk": "reviewed", "dataClass": "static-or-controlled-ui-template", @@ -92,7 +92,7 @@ }, { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js", - "line": 1294, + "line": 1313, "sink": ".innerHTML =", "risk": "high", "dataClass": "persisted-or-runtime-data", @@ -102,7 +102,7 @@ }, { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js", - "line": 3395, + "line": 3443, "sink": ".innerHTML =", "risk": "high", "dataClass": "persisted-or-runtime-data", @@ -112,7 +112,7 @@ }, { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.action-bar.js", - "line": 274, + "line": 312, "sink": ".innerHTML =", "risk": "high", "dataClass": "persisted-or-runtime-data", @@ -152,7 +152,7 @@ }, { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js", - "line": 471, + "line": 470, "sink": "insertAdjacentHTML(", "risk": "reviewed", "dataClass": "static-or-controlled-ui-template", @@ -162,7 +162,7 @@ }, { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js", - "line": 485, + "line": 484, "sink": ".innerHTML =", "risk": "high", "dataClass": "persisted-or-runtime-data", @@ -442,7 +442,7 @@ }, { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.ui.js", - "line": 85, + "line": 87, "sink": "insertAdjacentHTML(", "risk": "reviewed", "dataClass": "static-or-controlled-ui-template", @@ -452,7 +452,7 @@ }, { "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.ui.js", - "line": 273, + "line": 275, "sink": ".innerHTML =", "risk": "high", "dataClass": "persisted-or-runtime-data", @@ -486,7 +486,7 @@ "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page", "inlineEventAttributes": 0, "inlineScriptBlocks": 0, - "externalScriptTags": 31, + "externalScriptTags": 32, "inlineStyleBlocks": 0, "inlineStyleAttributes": 0, "evalCalls": 0, @@ -510,7 +510,7 @@ "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Dashboard.page", "inlineEventAttributes": 0, "inlineScriptBlocks": 0, - "externalScriptTags": 46, + "externalScriptTags": 47, "inlineStyleBlocks": 0, "inlineStyleAttributes": 0, "evalCalls": 0, @@ -522,7 +522,7 @@ "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.Docker.page", "inlineEventAttributes": 0, "inlineScriptBlocks": 0, - "externalScriptTags": 64, + "externalScriptTags": 67, "inlineStyleBlocks": 0, "inlineStyleAttributes": 0, "evalCalls": 0, @@ -534,7 +534,7 @@ "path": "src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/folderview.plus.VMs.page", "inlineEventAttributes": 0, "inlineScriptBlocks": 0, - "externalScriptTags": 38, + "externalScriptTags": 39, "inlineStyleBlocks": 0, "inlineStyleAttributes": 0, "evalCalls": 0, diff --git a/folderview.plus.plg b/folderview.plus.plg index a2ad93b3..9e179046 100644 --- a/folderview.plus.plg +++ b/folderview.plus.plg @@ -6,9 +6,9 @@ - - - + + + @@ -18,17 +18,103 @@ -###2026.08.24.04 -- Fix: Keep Docker preview context menus clickable in every visible row when Preview Rows is set to 2, 3, 4, or Unlimited, including after repeated layout reconciliation. -- Compatibility: Route Default-context preview activation through the original Unraid Docker control without copying host IDs or inline handlers, while preserving WebUI, logs, and console quick actions. -- Accessibility: Support first-click mouse activation from the preview icon, name, and status plus Enter and Space keyboard activation. -- Fix: Preserve the correct started, paused, and stopped styling on compact Docker preview status text and icons. -- Diagnostics: Add a short-lived, privacy-safe support-bundle summary of preview row mode and numeric row position, bridge binding and handler integrity, activation category, input method, dispatch outcomes, and bounded failure reasons. -- Privacy: Exclude folder and container identities, selectors, URLs, and pointer coordinates from preview context diagnostics. -- Maintenance: Add daily public-contract monitoring for Unraid stable and prerelease versions, PHP runtimes, Docker/VM/Dashboard and plugin-manager surfaces, Docker API/native-page signals, and Community Applications publication without live-Unraid credentials. -- Security: Add weekly OSV scanning of the generated SBOM, scheduled-workflow health coverage, and tool-scoped CodeQL alert enforcement so unrelated Scorecard findings do not fail CodeQL analysis. -- Compatibility: Refresh the reviewed Unraid/API baseline while retaining native-Docker safe mode and human approval for every upstream baseline change. -- Test: Cover multi-row and Unlimited preview interaction, repeated finalization, handler-loss detection, persistence, accessibility, status styling, and privacy boundaries in Chromium and Firefox. +###2026.08.28.05 +- Feature: Add a fourth **Hide** quick action to Docker folder menus, including nested-branch hiding, a temporary **Show hidden folders** view, **Restore all hidden folders**, and an immediate 10-second **Undo** action without deleting folder configuration or changing Unraid's native Host list. +- Feature: Add reusable custom WebUI profiles to each Docker folder so any number of named profiles can open only the selected running, unpaused container WebUIs, with create, duplicate, reorder, search, unavailable-member, and delete controls in the modern folder editor. +- Fix: Restore reliable container and child-folder context menus across single-row, multi-row, nested, collapsed, Preview Rows 1+, Unlimited, and Unraid Basic or Advanced View layouts, including mouse, right-click, touch, Enter, and Space activation. +- Fix: Show consistent lifecycle progress on single-row and multi-row Docker previews, keep Restart pending through its initial running snapshot, and use the action's destination status color until reconciliation completes. +- UI/UX: Use Unraid's native green started indicator with white status text by default, add a separate **Text** color picker, and apply customized Started, Paused, Stopped, and Text colors consistently across Docker, VM, Dashboard, inherited defaults, and starter templates. +- UI/UX: Make hidden-folder recovery, Undo, and custom WebUI profile controls theme-aware, responsive, and keyboard accessible instead of inheriting Unraid's orange button treatment. +- Reliability: Persist hidden-folder choices through conflict-safe preference updates, preserve profiles through clone, export/import, backup, restore, and unique member renames, and keep folder-specific profiles out of generic settings transfers and defaults that may target unrelated containers. +- Compatibility: Preserve explicitly selected white started indicators while migrating only the old unmodified white default to green, and acknowledge Unraid API v4.37.3 while keeping current legacy Docker behavior and future API capability monitoring bounded and fail-closed. +- Security: Reject malformed non-string folder names and container identifiers without invoking attacker-controlled coercion hooks, strengthen property-based input coverage, and tighten repository security-policy, CodeQL, workflow-permission, package-trust, and release contracts. +- Privacy: Extend sanitized diagnostics with aggregate context-menu, lifecycle, hidden-folder, and WebUI-profile health evidence while withholding folder names, container names, profile names, URLs, selectors, coordinates, and other runtime identities. +- Localization: Translate the new hidden-folder, WebUI-profile, and status-text interfaces across all 27 supported locales and keep dynamic Docker menu labels covered by strict localization validation. +- UX: Improve GitHub bug, support, feature, and translation intake forms with clearer reproduction, version, branch, diagnostics, privacy, compatibility, and testing fields. +- Maintenance: Add proactive Unraid/API compatibility checks, dependency and SBOM vulnerability monitoring, scheduled workflow watchdogs, manual CodeQL recovery checks, exact-tool CodeQL alert filtering, and validated main-to-dev back-merge ordering without introducing live-Unraid automation. +- Compatibility: No manual migration is required. Existing folders, assignments, rules, colors, backups, and imports remain compatible; hidden-folder state is reversible, and custom WebUI profiles remain scoped to their Docker folder. +- Test: Expand isolated browser, mobile, keyboard, theme, localization, fuzz, security, diagnostics, workflow, and large-library performance coverage for every behavior in this release. + + +###2026.08.28.04 +- Fix: Keep hidden-folder restore labels and custom WebUI profile warnings registered as active localized UI text. +- Reliability: Prevent strict localization validation and main-to-dev back-merges from rejecting those live Docker translation keys. +- Security: Reject malformed non-string folder names and container identifiers without invoking attacker-controlled coercion hooks. +- Test: Rebuild all 27 locale catalogs and verify hidden-folder behavior, localization coverage, and security workflow contracts. + + +###2026.08.28.03 +- UI/UX: Style every custom WebUI profile editor action with FolderView Plus controls instead of Unraid's default orange button treatment. +- Accessibility: Preserve visible keyboard focus and use the plugin's theme-aware text, surface, hover, active, disabled, and shadow tokens across dark and light themes. +- Test: Verify custom WebUI profile buttons override hostile host defaults and follow the final dark and light theme palettes in an isolated browser fixture. + + +###2026.08.28.02 +- Feature: Add reusable custom WebUI profiles to each Docker folder so one menu action can open only the selected container WebUIs. +- UI/UX: Add profile creation, naming, duplication, ordering, search, WebUI-aware selection, unavailable-member visibility, and deletion to the modern Docker folder editor, with responsive keyboard-accessible controls. +- Reliability: Resolve each selected container's current WebUI only when a profile is launched, open direct running and unpaused members only, retain renamed members through identity reconciliation, and fail closed when a profile no longer exists. +- Compatibility: Preserve folder-specific profiles through clone, full export/import, backup, and restore while excluding them from generic settings copy/paste, templates, and saved defaults that may target different containers. +- Privacy: Add support-bundle profile health counts without exporting profile names, container names, or WebUI addresses. +- Localization: Translate the custom WebUI profile editor and Docker menu across every supported locale. +- Docs: Document profile setup, launch behavior, popup handling, migration behavior, and current screenshot placeholders in the README and user guide. +- Test: Cover browser, mobile, keyboard, theme, persistence, launch eligibility, popup safety, rename, transfer, diagnostics, localization, and large-library performance behavior using isolated fixtures. + + +###2026.08.28.01 +- Fix: Show the 10-second Docker folder **Undo** notice immediately when a folder is hidden instead of waiting for preference persistence to finish. +- Reliability: Keep the optimistic hide and Undo actions conflict-safe, dismiss only the matching notice during restoration, and remove a failed hide notice before rolling the folder state back. +- UI/UX: Style Undo, Show hidden folders, and Restore all hidden folders with scoped theme tokens so Unraid host button rules cannot force an orange outline. +- Test: Cover deferred preference saves, save failure rollback, folder-aware notice dismissal, and 552 browser combinations across light and dark themes, desktop and mobile viewports, and Chromium, Firefox, and WebKit. + + +###2026.08.27.05 +- Feature: Add a fourth **Hide** quick action to Docker folder menus. Hiding a parent folder temporarily removes its complete nested branch from FolderView without deleting folders, assignments, rules, or settings. +- UX: Add a hidden-folder count, temporary **Show hidden folders** view, **Restore all hidden folders** action, and a 10-second **Undo** notice so hidden rows always remain recoverable. +- UX: Dim temporarily revealed folders and mark them as hidden while leaving Unraid's native Host list unchanged. +- Reliability: Persist hidden-folder choices through the existing conflict-safe preference flow, preserve the newest choice during rapid or cross-tab updates, clear focus when its folder is hidden, and prune references to deleted folders. +- Diagnostics: Add privacy-safe hidden-folder counts, hashed identifiers, integrity checks, and effective visibility state to runtime diagnostics and support bundles. +- Localization: Add the hidden-folder interface text to all 27 supported locale catalogs. +- Test: Cover nested branch hiding, reveal, restore-all, undo, preference conflicts, diagnostics, accessibility, browser rendering, themes, and runtime performance. + + +###2026.08.27.04 +- Fix: Restore container context actions in collapsed parent-folder previews when the folder contains child folders. +- Compatibility: Apply the correct Default or Advanced context behavior after nested previews are rebuilt, including Preview Rows 1+, Unlimited, and Unraid Basic or Advanced View. +- Compatibility: Preserve the parent folder's popup settings and keep child-folder preview actions independent. +- Test: Cover nested parent container actions in both context modes with Unraid Advanced View enabled, plus child-folder actions, browser fixtures, and large-runtime performance budgets. + + +###2026.08.27.03 +- Fix: Show the same pending lifecycle spinner for Docker containers in single-row and multi-row folder previews. +- Fix: Keep Start, Resume, and Restart feedback on the folder's Started color instead of temporarily using the Paused color; Pause and Stop continue to use their matching status colors. +- Compatibility: Honor each folder's customized Started, Paused, and Stopped indicator colors while lifecycle actions are pending. +- Test: Cover single-row lifecycle identity, action-specific pending colors, spinner cleanup, browser fixtures, responsive themes, and large-runtime performance budgets. + + +###2026.08.27.02 +- Fix: Restore native-style folder status presentation with green started indicators and independently white status text by default. +- Feature: Add a Text color picker to the modern folder editor Status section so status labels can be customized separately from Started, Paused, and Stopped indicators. +- Compatibility: Apply the independent status text color across Docker folders and previews, VM folders, Dashboard folders, inherited defaults, starter templates, and existing folders without requiring migration. +- Diagnostics: Include the normalized status text color in privacy-safe folder integrity diagnostics. +- Localization: Translate the new status text control and updated guidance across all 27 supported locales. +- Test: Cover status text normalization, editor persistence, runtime theme separation, localization, browser fixtures, responsive themes, and large-runtime performance budgets. + + +###2026.08.27.01 +- Fix: Use green as the default started-status color in the folder editor and across Docker, VM, and Dashboard folder surfaces. +- Compatibility: Migrate the previous unmodified white started-status default to green while preserving white when it is explicitly selected and saved in the modern folder editor. +- Fix: Show a visible pending spinner on compact multi-row Docker previews while a container lifecycle action, including Restart, is in progress. +- Fix: Keep Restart pending through the initial already-running snapshot, then restore the container preview to its current started, paused, or stopped state after reconciliation. +- Diagnostics: Record privacy-safe counts for lifecycle preview surfaces and pending or restored status icons without exporting container identities. +- Test: Cover legacy and explicit status-color behavior, compact-preview lifecycle feedback, restart timing, browser fixtures, responsive themes, and large-runtime performance budgets. + + +###2026.08.25.01 +- Fix: Open the same child-folder action menu from a collapsed parent preview when using a normal click, right-click, touch activation, Enter, or Space. +- Accessibility: Position keyboard-opened menus beside the child-folder preview chip, focus the first menu action, and retain the existing Expand, Edit, and Open folder actions. +- Diagnostics: Add bounded child-folder preview render, binding, menu-attempt, success, failure, and input-method counters to sanitized support bundles. +- Privacy: Keep child-folder preview diagnostics free of folder names, identifiers, selectors, URLs, and pointer coordinates. +- Test: Cover child-folder menu activation after repeated multi-row preview reconciliation and verify mouse, keyboard, context-menu, touch, focus, geometry, and diagnostic behavior in the browser fixture suite. ###2026.08.24.03 diff --git a/folderview.plus.xml b/folderview.plus.xml index c677cd37..5a7ada84 100644 --- a/folderview.plus.xml +++ b/folderview.plus.xml @@ -10,7 +10,7 @@ FolderView Plus organizes Docker, VM, and Dashboard views into folders in Unraid, with starter setup tools, rules, bulk assignment, recovery, templates, and diagnostics. -2026-08-24 +2026-08-28 7.0.0 folder view docker vm dashboard organization groups sorting import export backup recovery rules templates diagnostics https://forums.unraid.net/topic/197631-plugin-folderview-plus/ diff --git a/package-lock.json b/package-lock.json index a600d7aa..ea953161 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "devDependencies": { "axe-core": "4.13.0", "c8": "12.0.0", + "fast-check": "4.9.0", "jquery": "3.7.1", "opencc-js": "1.4.1", "playwright": "1.62.1" @@ -215,6 +216,29 @@ "node": ">=6" } }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -551,6 +575,23 @@ "node": ">=20" } }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", diff --git a/package.json b/package.json index a736e657..47883889 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "devDependencies": { "axe-core": "4.13.0", "c8": "12.0.0", + "fast-check": "4.9.0", "jquery": "3.7.1", "opencc-js": "1.4.1", "playwright": "1.62.1" diff --git a/scripts/classify_ci_changes.mjs b/scripts/classify_ci_changes.mjs index 6997f986..dfe082aa 100644 --- a/scripts/classify_ci_changes.mjs +++ b/scripts/classify_ci_changes.mjs @@ -27,8 +27,10 @@ export const FILTERS = Object.freeze({ workflows: [ '.github/workflows/**', '.github/actions/**', + '.github/ISSUE_TEMPLATE/**', 'scripts/classify_ci_changes.mjs', 'scripts/actionlint_guard.sh', + 'scripts/issue_form_guard.mjs', 'scripts/run_ci_suite.sh', 'scripts/test_runner_contract_guard.mjs', 'scripts/build_release_notes.sh', diff --git a/scripts/csp_readiness_guard.mjs b/scripts/csp_readiness_guard.mjs index 76365521..c89d6b8f 100644 --- a/scripts/csp_readiness_guard.mjs +++ b/scripts/csp_readiness_guard.mjs @@ -45,13 +45,13 @@ const patterns = { }; const lineNumberAt = (source, index) => source.slice(0, index).split('\n').length; const explicitHtmlSinkReviews = new Map([ - ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js:1294', 'Port endpoints and protocols are escaped by buildDockerPortEndpoint before the markup builder returns.'], - ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js:3395', 'The loading overlay contains plugin-authored static markup only.'], - ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.action-bar.js:274', 'Action, label, icon, title, and menu values are escaped by the local markup builders.'], + ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js:1313', 'Port endpoints and protocols are escaped by buildDockerPortEndpoint before the markup builder returns.'], + ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js:3443', 'The loading overlay contains plugin-authored static markup only.'], + ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.action-bar.js:312', 'Action, label, icon, title, and menu values are escaped by the local markup builders.'], ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.command-view.js:504', 'Names, identifiers, states, actions, and error text are escaped; counts are normalized numbers and image sources are sanitized.'], ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.shared-controls.js:221', 'The stable-toggle controller accepts only plugin-owned markup builders and validates the expected input after mounting.'], - ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js:471', 'buildTopChrome returns plugin-authored editor chrome with no persisted-value interpolation.'], - ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js:485', 'The action bar shell contains plugin-authored static markup only.'], + ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js:470', 'buildTopChrome returns plugin-authored editor chrome with no persisted-value interpolation.'], + ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js:484', 'The action bar shell contains plugin-authored static markup only.'], ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js:604', 'Panel titles and descriptions come from the frozen plugin-authored section metadata table.'], ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js:680', 'Section keys and labels come from the plugin-authored editor metadata tables.'], ['src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js:832', 'The preview image source is a fixed plugin asset path.'], diff --git a/scripts/fixture_browser_tests.mjs b/scripts/fixture_browser_tests.mjs index 17a191a0..976ccdea 100644 --- a/scripts/fixture_browser_tests.mjs +++ b/scripts/fixture_browser_tests.mjs @@ -9,11 +9,12 @@ import { registerFoundationFixtureCases } from '../tests/browser/cases/foundatio import { registerDockerFixtureCases } from '../tests/browser/cases/docker.mjs'; import { registerDashboardLifecycleFixtureCases } from '../tests/browser/cases/dashboard-lifecycle.mjs'; import { registerRuntimeInteractionsFixtureCases } from '../tests/browser/cases/runtime-interactions.mjs'; +import { registerDockerHiddenFolderFixtureCases } from '../tests/browser/cases/docker-hidden-folders.mjs'; import { registerSettingsFixtureCases } from '../tests/browser/cases/settings.mjs'; import { registerSettingsStartOrderFixtureCases } from '../tests/browser/cases/settings-start-order.mjs'; import { registerFolderEditorFixtureCases } from '../tests/browser/cases/folder-editor.mjs'; +import { registerFolderWebuiProfileFixtureCases } from '../tests/browser/cases/folder-webui-profiles.mjs'; import { registerFolderWebuiProfileThemeFixtureCase } from '../tests/browser/cases/folder-webui-profile-theme.mjs'; import { registerImportFixtureCases } from '../tests/browser/cases/import.mjs'; - const scriptPath = fileURLToPath(import.meta.url); const rootDir = path.resolve(path.dirname(scriptPath), '..'); const pluginDir = path.join(rootDir, 'src', 'folderview.plus', 'usr', 'local', 'emhttp', 'plugins', 'folderview.plus'); @@ -45,9 +46,10 @@ registerFoundationFixtureCases(caseContext); registerDockerFixtureCases(caseContext); registerDashboardLifecycleFixtureCases(caseContext); registerRuntimeInteractionsFixtureCases(caseContext); +registerDockerHiddenFolderFixtureCases(caseContext); registerSettingsFixtureCases(caseContext); registerSettingsStartOrderFixtureCases(caseContext); -registerFolderEditorFixtureCases(caseContext); +registerFolderEditorFixtureCases(caseContext); registerFolderWebuiProfileFixtureCases(caseContext); registerFolderWebuiProfileThemeFixtureCase(caseContext); registerImportFixtureCases(caseContext); await runFixtureBrowserSuite({ diff --git a/scripts/install_smoke.sh b/scripts/install_smoke.sh index 559764b5..ad09a976 100644 --- a/scripts/install_smoke.sh +++ b/scripts/install_smoke.sh @@ -86,6 +86,7 @@ REQUIRED_ARCHIVE_ENTRIES=( "./usr/local/emhttp/plugins/folderview.plus/scripts/archive_preflight.sh" "./usr/local/emhttp/plugins/folderview.plus/scripts/install_icon_asset_pack.sh" "./usr/local/emhttp/plugins/folderview.plus/scripts/install_report.sh" + "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.child-folder-preview-menu.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.column-controller.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.actions.js" @@ -209,6 +210,7 @@ REQUIRED_FILES=( "scripts/archive_preflight.sh" "scripts/install_icon_asset_pack.sh" "scripts/install_report.sh" + "scripts/docker.runtime.child-folder-preview-menu.js" "scripts/docker.runtime.hierarchy.js" "scripts/docker.runtime.column-controller.js" "scripts/docker.runtime.actions.js" diff --git a/scripts/issue_form_guard.mjs b/scripts/issue_form_guard.mjs new file mode 100644 index 00000000..b21ea671 --- /dev/null +++ b/scripts/issue_form_guard.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = path.resolve(process.cwd()); +const failures = []; + +const fail = (message) => failures.push(message); +const read = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); +const formPaths = Object.freeze([ + '.github/ISSUE_TEMPLATE/bug_report.yml', + '.github/ISSUE_TEMPLATE/feature_request.yml', + '.github/ISSUE_TEMPLATE/support_troubleshooting.yml', + '.github/ISSUE_TEMPLATE/translation.yml' +]); + +const findBodyBlock = (source, id) => { + const lines = source.split(/\r?\n/); + const idIndex = lines.findIndex((line) => line === ` id: ${id}`); + if (idIndex < 0) return ''; + let start = idIndex; + while (start >= 0 && !lines[start].startsWith(' - type:')) start -= 1; + let end = idIndex + 1; + while (end < lines.length && !lines[end].startsWith(' - type:')) end += 1; + return lines.slice(Math.max(0, start), end).join('\n'); +}; + +const extractOptions = (source, id) => { + const block = findBodyBlock(source, id); + if (!block) return []; + const lines = block.split(/\r?\n/); + const optionsIndex = lines.findIndex((line) => line === ' options:'); + if (optionsIndex < 0) return []; + return lines.slice(optionsIndex + 1) + .filter((line) => line.startsWith(' - ')) + .map((line) => line.slice(' - '.length).trim()); +}; + +const requireText = (source, text, label) => { + if (!source.includes(text)) fail(`${label} is missing required text: ${text}`); +}; + +const requireIds = (source, relativePath, ids) => { + for (const id of ids) { + const block = findBodyBlock(source, id); + if (!block) { + fail(`${relativePath} is missing required field id: ${id}`); + continue; + } + if (id !== 'relevant_settings' && !/\n validations:\n required: true(?:\n|$)/.test(block)) { + fail(`${relativePath} field ${id} must be required.`); + } + } +}; + +const requireOptions = (source, relativePath, id, expectedOptions) => { + const options = extractOptions(source, id); + for (const expected of expectedOptions) { + if (!options.includes(expected)) { + fail(`${relativePath} field ${id} is missing option: ${expected}`); + } + } +}; + +const requireRequiredCheckbox = (source, relativePath, id) => { + const block = findBodyBlock(source, id); + if (!block) { + fail(`${relativePath} is missing required checkbox field id: ${id}`); + } else if (!/\n required: true(?:\n|$)/.test(block)) { + fail(`${relativePath} checkbox field ${id} must contain a required confirmation.`); + } +}; + +for (const relativePath of formPaths) { + const fullPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(fullPath)) { + fail(`Required issue form is missing: ${relativePath}`); + continue; + } + const source = fs.readFileSync(fullPath, 'utf8'); + const ids = [...source.matchAll(/^ id: ([A-Za-z][A-Za-z0-9_-]*)\s*$/gm)].map((match) => match[1]); + const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index); + if (duplicates.length) { + fail(`${relativePath} contains duplicate field ids: ${[...new Set(duplicates)].join(', ')}`); + } + for (const key of ['name:', 'description:', 'title:', 'labels:', 'body:']) { + if (!source.includes(key)) fail(`${relativePath} is missing top-level key: ${key}`); + } +} + +const bugPath = '.github/ISSUE_TEMPLATE/bug_report.yml'; +const bug = read(bugPath); +requireIds(bug, bugPath, [ + 'unraid_version', 'plugin_version', 'release_channel', 'browser_device', 'install_method', + 'access_method', 'docker_view_mode', 'theme_context', 'conflict_plugins', 'configuration_origin', + 'affected_area', 'reproducibility', 'steps_to_reproduce', 'expected_behavior', 'actual_behavior', + 'relevant_settings', 'support_bundle_status' +]); +requireOptions(bug, bugPath, 'affected_area', [ + 'Docker container actions / context menus', + 'Docker API / provider compatibility', + 'Unraid native Docker / prerelease compatibility', + 'Reverse proxy / request guard', + 'Diagnostics / support bundles', + 'Docker Start Order', + 'Privacy / security behavior' +]); +requireOptions(bug, bugPath, 'release_channel', ['Stable / main', 'Dev testing build', 'Commit-specific build']); +requireOptions(bug, bugPath, 'access_method', ['Direct LAN IP', 'Reverse proxy or custom domain', 'Unraid Connect']); +requireOptions(bug, bugPath, 'docker_view_mode', ['Basic View', 'Advanced View', 'Tested in both', 'Not applicable']); +requireOptions(bug, bugPath, 'configuration_origin', ['Fresh FolderView Plus configuration', 'Imported from FolderView3', 'Imported from FolderView2', 'Restored from a backup or export']); +requireOptions(bug, bugPath, 'support_bundle_status', ['Attached below', 'Unable to export because Settings or Diagnostics will not load', 'Unable to export because installation failed', 'Need instructions']); +requireRequiredCheckbox(bug, bugPath, 'privacy_confirmation'); +for (const text of [ + 'latest stable release', + 'Advanced View', + 'Reverse proxy or custom domain', + 'Imported from FolderView3', + 'Sanitized support bundle status', + 'full/unsanitized bundle', + 'raw configuration export' +]) requireText(bug, text, bugPath); +if (bug.includes('main or latest commit URL')) { + fail(`${bugPath} must not use the ambiguous latest-commit pre-check wording.`); +} + +const supportPath = '.github/ISSUE_TEMPLATE/support_troubleshooting.yml'; +const support = read(supportPath); +requireIds(support, supportPath, [ + 'help_topic', 'unraid_version', 'plugin_version', 'release_channel', 'install_source', + 'browser_device', 'access_method', 'docker_view_mode', 'theme_context', 'conflict_plugins', + 'configuration_origin', 'current_behavior', 'expected_behavior', 'troubleshooting_tried', + 'relevant_settings', 'support_bundle_status' +]); +requireOptions(support, supportPath, 'help_topic', [ + 'Docker or VM actions / context menus', + 'Reverse proxy or custom hostname', + 'Diagnostics or support-bundle export', + 'Docker Start Order', + 'Unraid compatibility', + 'Performance or refresh behavior', + 'Privacy or security behavior' +]); +requireOptions(support, supportPath, 'release_channel', ['Stable / main', 'Dev testing build', 'Commit-specific build']); +requireOptions(support, supportPath, 'access_method', ['Direct LAN IP', 'Reverse proxy or custom domain', 'Unraid Connect']); +requireOptions(support, supportPath, 'docker_view_mode', ['Basic View', 'Advanced View', 'Tested in both', 'Not applicable']); +requireOptions(support, supportPath, 'configuration_origin', ['Fresh FolderView Plus configuration', 'Imported from FolderView3', 'Imported from FolderView2', 'Restored from a backup or export']); +requireOptions(support, supportPath, 'support_bundle_status', ['Attached below', 'Unable to export because Settings or Diagnostics will not load', 'Unable to export because installation failed', 'Need instructions']); +requireRequiredCheckbox(support, supportPath, 'checklist'); +for (const text of [ + 'Advanced View', + 'Reverse proxy or custom domain', + 'Imported from FolderView3', + 'Sanitized support bundle status', + 'full/unsanitized bundle', + 'raw configuration export', + 'I reviewed the Wiki and troubleshooting guidance' +]) requireText(support, text, supportPath); + +const featurePath = '.github/ISSUE_TEMPLATE/feature_request.yml'; +const feature = read(featurePath); +requireOptions(feature, featurePath, 'area', [ + 'Docker container actions / context menus', + 'Docker API / provider compatibility', + 'Unraid native Docker / prerelease compatibility', + 'Docker Start Order', + 'Diagnostics / support bundles', + 'Reverse proxy / access paths', + 'Privacy / security behavior' +]); + +const translationPath = '.github/ISSUE_TEMPLATE/translation.yml'; +const translation = read(translationPath); +const localeDirectory = path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/langs'); +const expectedLocales = fs.readdirSync(localeDirectory) + .filter((name) => name.endsWith('.json')) + .map((name) => path.basename(name, '.json')) + .filter((locale) => !['en', 'extraction-report'].includes(locale)) + .sort(); +const languageOptions = extractOptions(translation, 'language'); +const optionLocales = languageOptions + .map((option) => option.match(/\(([A-Za-z]{2,3}(?:-[A-Za-z0-9]+)*)\)$/)?.[1] || '') + .filter(Boolean) + .sort(); +const missingLocales = expectedLocales.filter((locale) => !optionLocales.includes(locale)); +const extraLocales = optionLocales.filter((locale) => !expectedLocales.includes(locale)); +const duplicateLocales = optionLocales.filter((locale, index) => optionLocales.indexOf(locale) !== index); +if (missingLocales.length) fail(`${translationPath} is missing shipped locale options: ${missingLocales.join(', ')}`); +if (extraLocales.length) fail(`${translationPath} contains locale options without shipped catalogs: ${extraLocales.join(', ')}`); +if (duplicateLocales.length) fail(`${translationPath} contains duplicate locale options: ${[...new Set(duplicateLocales)].join(', ')}`); +if (!languageOptions.includes('Another locale or regional variant')) { + fail(`${translationPath} must preserve the new-locale contribution option.`); +} +requireText(translation, 'Translation-and-Accessibility', translationPath); + +const configPath = '.github/ISSUE_TEMPLATE/config.yml'; +const config = read(configPath); +for (const text of [ + 'blank_issues_enabled: false', + 'https://github.com/alexphillips-dev/FolderView-Plus/wiki', + 'https://github.com/alexphillips-dev/FolderView-Plus/wiki/Troubleshooting', + 'https://github.com/alexphillips-dev/FolderView-Plus/wiki/Activity-Diagnostics-and-Support', + 'https://github.com/alexphillips-dev/FolderView-Plus/security/policy' +]) requireText(config, text, configPath); + +const supportGuidePath = '.github/SUPPORT.md'; +const supportGuide = read(supportGuidePath); +for (const text of [ + 'installed release channel', + 'access path', + 'Docker Basic/Advanced View status', + 'configuration origin', + 'relevant FolderView Plus settings', + 'sanitized support-bundle status', + 'raw configuration export publicly' +]) requireText(supportGuide, text, supportGuidePath); + +if (failures.length) { + for (const message of failures) console.error(`ERROR: ${message}`); + process.exit(1); +} + +console.log(`Issue form guard passed: ${formPaths.length} forms, ${expectedLocales.length} translated locales, and diagnostic/privacy contracts are synchronized.`); diff --git a/scripts/lang_usage_guard.sh b/scripts/lang_usage_guard.sh index 5faa652f..c2102f0e 100644 --- a/scripts/lang_usage_guard.sh +++ b/scripts/lang_usage_guard.sh @@ -174,6 +174,15 @@ for (const fullPath of sourceFiles.sort()) { referencedKeys.get(key).push(`${relPath}:${line}`); } + const qualifiedMenuLabelRegex = /\bgetDockerMenuLabel\(\s*['"]([^'"]+\.[^'"]+)['"]/g; + while ((match = qualifiedMenuLabelRegex.exec(source)) !== null) { + const key = match[1].trim(); + if (!key) continue; + const line = lineNumberAt(source, match.index); + if (!referencedKeys.has(key)) referencedKeys.set(key, []); + referencedKeys.get(key).push(`${relPath}:${line}`); + } + const declaredApplicationKeyRegexes = [ /\bi18nKey\s*:\s*['"]([^'"]+)['"]/g, /\b(?:labelKey|placeholderKey)\s*:\s*['"](editor\.rules\.templates\.[^'"]+)['"]/g diff --git a/scripts/load_order_contracts.json b/scripts/load_order_contracts.json index 6fdc1bf3..3ef732ac 100644 --- a/scripts/load_order_contracts.json +++ b/scripts/load_order_contracts.json @@ -102,6 +102,7 @@ "scripts/folderviewplus.request.js", "scripts/runtime.performance-telemetry.js", "scripts/folderviewplus.runtime-snapshot.js", + "scripts/folder.webui-profiles.js", "scripts/folder.member-identity.js", "scripts/runtime.privacy.js", "scripts/runtime.transport.core.js", @@ -138,6 +139,7 @@ "scripts/docker.modules.js", "scripts/docker.runtime.info.js", "scripts/docker.runtime.preview-actions.js", + "scripts/docker.runtime.child-folder-preview-menu.js", "scripts/docker.runtime.hierarchy.js", "scripts/docker.runtime.actions.js", "scripts/docker.runtime.host-guards.js", @@ -145,6 +147,7 @@ "scripts/docker.runtime.diagnostics.js", "scripts/docker.runtime.reconcile.js", "scripts/docker.runtime.command-view.js", + "scripts/docker.runtime.hidden-folders.js", "scripts/docker.runtime.action-bar.js", "scripts/docker.js", "scripts/docker.page-bootstrap.js", @@ -169,6 +172,7 @@ "scripts/folderviewplus.request.js", "scripts/runtime.performance-telemetry.js", "scripts/folderviewplus.runtime-snapshot.js", + "scripts/folder.webui-profiles.js", "scripts/folder.member-identity.js", "scripts/runtime.privacy.js", "scripts/runtime.transport.core.js", @@ -217,6 +221,7 @@ "scripts/folderviewplus.request.js", "scripts/runtime.performance-telemetry.js", "scripts/folderviewplus.runtime-snapshot.js", + "scripts/folder.webui-profiles.js", "scripts/folder.member-identity.js", "scripts/runtime.privacy.js", "scripts/runtime.transport.core.js", @@ -274,6 +279,7 @@ "scripts/folderviewplus.ui.js", "scripts/folderviewplus.request.js", "scripts/runtime.performance-telemetry.js", + "scripts/folder.webui-profiles.js", "scripts/folder.member-identity.js", "scripts/folderviewplus.prefs-store.js", "scripts/folderviewplus.theme-surface.js", diff --git a/scripts/release_guard.sh b/scripts/release_guard.sh index 43dc1a50..a36bf6ff 100644 --- a/scripts/release_guard.sh +++ b/scripts/release_guard.sh @@ -202,6 +202,7 @@ if [[ -z "${ARCHIVE_SIZE_BYTES}" || "${ARCHIVE_SIZE_BYTES}" -gt "${MAX_ARCHIVE_B fi SOURCE_FOLDER_JS="${ROOT_DIR}/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.js" +SOURCE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS="${ROOT_DIR}/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.child-folder-preview-menu.js" SOURCE_DOCKER_RUNTIME_HIERARCHY_JS="${ROOT_DIR}/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js" SOURCE_DOCKER_RUNTIME_ACTIONS_JS="${ROOT_DIR}/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.actions.js" SOURCE_FOLDER_SETTINGS_TRANSFER_JS="${ROOT_DIR}/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.settings-transfer.js" @@ -241,6 +242,11 @@ if [[ ! -f "${SOURCE_FOLDER_JS}" ]]; then echo "ERROR: Missing source folder editor script: ${SOURCE_FOLDER_JS}" >&2 exit 1 fi +if [[ ! -f "${SOURCE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS}" ]]; then + echo "ERROR: Missing source child-folder preview menu helper script: ${SOURCE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS}" >&2 + exit 1 +fi + if [[ ! -f "${SOURCE_DOCKER_RUNTIME_HIERARCHY_JS}" ]]; then echo "ERROR: Missing source Docker hierarchy helper script: ${SOURCE_DOCKER_RUNTIME_HIERARCHY_JS}" >&2 exit 1 @@ -429,6 +435,7 @@ REQUIRED_ARCHIVE_PATHS=( "./usr/local/emhttp/plugins/folderview.plus/scripts/install_report.sh" "./usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.csp-events.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/folder.js" + "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.child-folder-preview-menu.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.actions.js" "./usr/local/emhttp/plugins/folderview.plus/scripts/folder.settings-transfer.js" @@ -734,6 +741,7 @@ if ! grep -Eq "${current_version_notes_pattern}" "${RELEASE_NOTES_LIB}"; then fi TMP_ARCHIVE_FOLDER_JS="$(mktemp)" +TMP_ARCHIVE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS="$(mktemp)" TMP_ARCHIVE_DOCKER_RUNTIME_HIERARCHY_JS="$(mktemp)" TMP_ARCHIVE_DOCKER_RUNTIME_ACTIONS_JS="$(mktemp)" TMP_ARCHIVE_FOLDER_SETTINGS_TRANSFER_JS="$(mktemp)" @@ -768,8 +776,9 @@ TMP_ARCHIVE_SERVER_LIB="$(mktemp)" TMP_ARCHIVE_SERVER_LIB_DIAGNOSTICS="$(mktemp)" TMP_ARCHIVE_SERVER_APPLY_FOLDER_SETTINGS="$(mktemp)" TMP_ARCHIVE_SERVER_UPDATE_NOTES="$(mktemp)" -trap 'rm -f "${TMP_ARCHIVE_FOLDER_JS}" "${TMP_ARCHIVE_DOCKER_RUNTIME_HIERARCHY_JS}" "${TMP_ARCHIVE_DOCKER_RUNTIME_ACTIONS_JS}" "${TMP_ARCHIVE_FOLDER_SETTINGS_TRANSFER_JS}" "${TMP_ARCHIVE_FOLDER_CSS}" "${TMP_ARCHIVE_SETTINGS_JS}" "${TMP_ARCHIVE_SETTINGS_DIRTY_JS}" "${TMP_ARCHIVE_SETTINGS_RUNTIME_PARITY_JS}" "${TMP_ARCHIVE_SETTINGS_SECTIONS_JS}" "${TMP_ARCHIVE_SETTINGS_SETUP_ASSISTANT_JS}" "${TMP_ARCHIVE_SETTINGS_SMART_DETECT_CONFIG_JS}" "${TMP_ARCHIVE_SETTINGS_STARTER_TEMPLATES_JS}" "${TMP_ARCHIVE_SETTINGS_SUPPORT_BUNDLE_PREVIEW_JS}" "${TMP_ARCHIVE_SETTINGS_DOWNLOAD_DIAGNOSTICS_JS}" "${TMP_ARCHIVE_SETTINGS_SUPPORT_BUNDLE_TELEMETRY_JS}" "${TMP_ARCHIVE_SETTINGS_ACTIVITY_DIAGNOSTICS_JS}" "${TMP_ARCHIVE_SETTINGS_TREE_JS}" "${TMP_ARCHIVE_SETTINGS_TREE_INTEGRITY_JS}" "${TMP_ARCHIVE_SETTINGS_MOBILE_REORDER_JS}" "${TMP_ARCHIVE_SETTINGS_FOLDER_EDITOR_JS}" "${TMP_ARCHIVE_SETTINGS_HEALTH_JS}" "${TMP_ARCHIVE_SETTINGS_WORKSPACES_JS}" "${TMP_ARCHIVE_SETTINGS_BULK_ASSIGNMENT_SHARED_JS}" "${TMP_ARCHIVE_SETTINGS_BULK_ASSIGNMENT_JS}" "${TMP_ARCHIVE_SETTINGS_RUNTIME_ACTIONS_JS}" "${TMP_ARCHIVE_SETTINGS_WIZARD_JS}" "${TMP_ARCHIVE_SETTINGS_IMPORT_JS}" "${TMP_ARCHIVE_SETTINGS_CSS}" "${TMP_ARCHIVE_SETTINGS_DOWNLOAD_DIAGNOSTICS_CSS}" "${TMP_ARCHIVE_FOLDER_PAGE}" "${TMP_ARCHIVE_SETTINGS_PAGE}" "${TMP_ARCHIVE_SERVER_LIB}" "${TMP_ARCHIVE_SERVER_LIB_DIAGNOSTICS}" "${TMP_ARCHIVE_SERVER_APPLY_FOLDER_SETTINGS}" "${TMP_ARCHIVE_SERVER_UPDATE_NOTES}"' EXIT +trap 'rm -f "${TMP_ARCHIVE_FOLDER_JS}" "${TMP_ARCHIVE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS}" "${TMP_ARCHIVE_DOCKER_RUNTIME_HIERARCHY_JS}" "${TMP_ARCHIVE_DOCKER_RUNTIME_ACTIONS_JS}" "${TMP_ARCHIVE_FOLDER_SETTINGS_TRANSFER_JS}" "${TMP_ARCHIVE_FOLDER_CSS}" "${TMP_ARCHIVE_SETTINGS_JS}" "${TMP_ARCHIVE_SETTINGS_DIRTY_JS}" "${TMP_ARCHIVE_SETTINGS_RUNTIME_PARITY_JS}" "${TMP_ARCHIVE_SETTINGS_SECTIONS_JS}" "${TMP_ARCHIVE_SETTINGS_SETUP_ASSISTANT_JS}" "${TMP_ARCHIVE_SETTINGS_SMART_DETECT_CONFIG_JS}" "${TMP_ARCHIVE_SETTINGS_STARTER_TEMPLATES_JS}" "${TMP_ARCHIVE_SETTINGS_SUPPORT_BUNDLE_PREVIEW_JS}" "${TMP_ARCHIVE_SETTINGS_DOWNLOAD_DIAGNOSTICS_JS}" "${TMP_ARCHIVE_SETTINGS_SUPPORT_BUNDLE_TELEMETRY_JS}" "${TMP_ARCHIVE_SETTINGS_ACTIVITY_DIAGNOSTICS_JS}" "${TMP_ARCHIVE_SETTINGS_TREE_JS}" "${TMP_ARCHIVE_SETTINGS_TREE_INTEGRITY_JS}" "${TMP_ARCHIVE_SETTINGS_MOBILE_REORDER_JS}" "${TMP_ARCHIVE_SETTINGS_FOLDER_EDITOR_JS}" "${TMP_ARCHIVE_SETTINGS_HEALTH_JS}" "${TMP_ARCHIVE_SETTINGS_WORKSPACES_JS}" "${TMP_ARCHIVE_SETTINGS_BULK_ASSIGNMENT_SHARED_JS}" "${TMP_ARCHIVE_SETTINGS_BULK_ASSIGNMENT_JS}" "${TMP_ARCHIVE_SETTINGS_RUNTIME_ACTIONS_JS}" "${TMP_ARCHIVE_SETTINGS_WIZARD_JS}" "${TMP_ARCHIVE_SETTINGS_IMPORT_JS}" "${TMP_ARCHIVE_SETTINGS_CSS}" "${TMP_ARCHIVE_SETTINGS_DOWNLOAD_DIAGNOSTICS_CSS}" "${TMP_ARCHIVE_FOLDER_PAGE}" "${TMP_ARCHIVE_SETTINGS_PAGE}" "${TMP_ARCHIVE_SERVER_LIB}" "${TMP_ARCHIVE_SERVER_LIB_DIAGNOSTICS}" "${TMP_ARCHIVE_SERVER_APPLY_FOLDER_SETTINGS}" "${TMP_ARCHIVE_SERVER_UPDATE_NOTES}"' EXIT ARCHIVE_FOLDER_JS_PATH="./usr/local/emhttp/plugins/folderview.plus/scripts/folder.js" +ARCHIVE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS_PATH="./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.child-folder-preview-menu.js" ARCHIVE_DOCKER_RUNTIME_HIERARCHY_JS_PATH="./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js" ARCHIVE_DOCKER_RUNTIME_ACTIONS_JS_PATH="./usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.actions.js" ARCHIVE_FOLDER_SETTINGS_TRANSFER_JS_PATH="./usr/local/emhttp/plugins/folderview.plus/scripts/folder.settings-transfer.js" @@ -807,6 +816,9 @@ ARCHIVE_SERVER_UPDATE_NOTES_PATH="./usr/local/emhttp/plugins/folderview.plus/ser if ! grep -Fxq "${ARCHIVE_FOLDER_JS_PATH}" <<< "${ARCHIVE_LIST}"; then ARCHIVE_FOLDER_JS_PATH="${ARCHIVE_FOLDER_JS_PATH#./}" fi +if ! grep -Fxq "${ARCHIVE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS_PATH}" <<< "${ARCHIVE_LIST}"; then + ARCHIVE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS_PATH="${ARCHIVE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS_PATH#./}" +fi if ! grep -Fxq "${ARCHIVE_DOCKER_RUNTIME_HIERARCHY_JS_PATH}" <<< "${ARCHIVE_LIST}"; then ARCHIVE_DOCKER_RUNTIME_HIERARCHY_JS_PATH="${ARCHIVE_DOCKER_RUNTIME_HIERARCHY_JS_PATH#./}" fi @@ -910,6 +922,7 @@ if ! grep -Fxq "${ARCHIVE_SERVER_UPDATE_NOTES_PATH}" <<< "${ARCHIVE_LIST}"; then ARCHIVE_SERVER_UPDATE_NOTES_PATH="${ARCHIVE_SERVER_UPDATE_NOTES_PATH#./}" fi tar -xOf "${ARCHIVE_FILE}" "${ARCHIVE_FOLDER_JS_PATH}" > "${TMP_ARCHIVE_FOLDER_JS}" +tar -xOf "${ARCHIVE_FILE}" "${ARCHIVE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS_PATH}" > "${TMP_ARCHIVE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS}" tar -xOf "${ARCHIVE_FILE}" "${ARCHIVE_DOCKER_RUNTIME_HIERARCHY_JS_PATH}" > "${TMP_ARCHIVE_DOCKER_RUNTIME_HIERARCHY_JS}" tar -xOf "${ARCHIVE_FILE}" "${ARCHIVE_DOCKER_RUNTIME_ACTIONS_JS_PATH}" > "${TMP_ARCHIVE_DOCKER_RUNTIME_ACTIONS_JS}" tar -xOf "${ARCHIVE_FILE}" "${ARCHIVE_FOLDER_SETTINGS_TRANSFER_JS_PATH}" > "${TMP_ARCHIVE_FOLDER_SETTINGS_TRANSFER_JS}" @@ -950,6 +963,10 @@ if ! env_truthy "${FVPLUS_ALLOW_PACKAGED_SOURCE_DRIFT:-0}"; then fail_packaged_source_mismatch "Packaged folder.js does not match source folder.js." fi + if ! text_files_match "${SOURCE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS}" "${TMP_ARCHIVE_DOCKER_CHILD_FOLDER_PREVIEW_MENU_JS}"; then + fail_packaged_source_mismatch "Packaged docker.runtime.child-folder-preview-menu.js does not match source docker.runtime.child-folder-preview-menu.js." + fi + if ! text_files_match "${SOURCE_DOCKER_RUNTIME_HIERARCHY_JS}" "${TMP_ARCHIVE_DOCKER_RUNTIME_HIERARCHY_JS}"; then fail_packaged_source_mismatch "Packaged docker.runtime.hierarchy.js does not match source docker.runtime.hierarchy.js." fi diff --git a/scripts/run_ci_suite.sh b/scripts/run_ci_suite.sh index 75df2c96..f8bc7a31 100644 --- a/scripts/run_ci_suite.sh +++ b/scripts/run_ci_suite.sh @@ -249,6 +249,7 @@ run_lane() { ;; workflow-guards) run_timed_step actionlint bash scripts/actionlint_guard.sh + run_timed_step issue-form-contract "${NODE_BIN}" "$(fvplus::path_for_command "${NODE_BIN}" "scripts/issue_form_guard.mjs")" run_timed_step docs-metadata bash scripts/docs_metadata_guard.sh run_timed_step release-notes-consistency bash scripts/release_notes_consistency_guard.sh run_timed_step workflow-self-check bash scripts/workflow_self_check.sh @@ -275,6 +276,7 @@ run_lane() { run_timed_step repro-build bash scripts/repro_build_guard.sh run_timed_step docs-metadata bash scripts/docs_metadata_guard.sh run_timed_step release-notes-consistency bash scripts/release_notes_consistency_guard.sh + run_timed_step issue-form-contract "${NODE_BIN}" "$(fvplus::path_for_command "${NODE_BIN}" "scripts/issue_form_guard.mjs")" run_timed_step workflow-self-check bash scripts/workflow_self_check.sh ;; fixture-browser) diff --git a/scripts/test_runner_contracts.json b/scripts/test_runner_contracts.json index 6d0d9689..1cd3eafe 100644 --- a/scripts/test_runner_contracts.json +++ b/scripts/test_runner_contracts.json @@ -13,9 +13,12 @@ "../tests/browser/cases/docker.mjs", "../tests/browser/cases/dashboard-lifecycle.mjs", "../tests/browser/cases/runtime-interactions.mjs", + "../tests/browser/cases/docker-hidden-folders.mjs", "../tests/browser/cases/settings.mjs", "../tests/browser/cases/settings-start-order.mjs", "../tests/browser/cases/folder-editor.mjs", + "../tests/browser/cases/folder-webui-profiles.mjs", + "../tests/browser/cases/folder-webui-profile-theme.mjs", "../tests/browser/cases/import.mjs" ] } @@ -28,9 +31,13 @@ { "file": "tests/browser/cases/docker.mjs", "limit": 220, "owner": "fixture Docker cases" }, { "file": "tests/browser/cases/dashboard-lifecycle.mjs", "limit": 365, "owner": "fixture Dashboard and VM lifecycle cases" }, { "file": "tests/browser/cases/runtime-interactions.mjs", "limit": 155, "owner": "fixture runtime interaction cases" }, + { "file": "tests/browser/cases/docker-hidden-folders.mjs", "limit": 27, "owner": "fixture hidden-folder recovery cases" }, + { "file": "tests/browser/helpers/docker-hidden-folder-context.mjs", "limit": 32, "owner": "fixture hidden-folder timing and theme assertions" }, { "file": "tests/browser/cases/settings.mjs", "limit": 610, "owner": "fixture Settings cases" }, { "file": "tests/browser/cases/settings-start-order.mjs", "limit": 48, "owner": "fixture Docker start-order Settings cases" }, { "file": "tests/browser/cases/folder-editor.mjs", "limit": 44, "owner": "fixture Folder Editor cases" }, + { "file": "tests/browser/cases/folder-webui-profiles.mjs", "limit": 32, "owner": "fixture Folder WebUI profile cases" }, + { "file": "tests/browser/cases/folder-webui-profile-theme.mjs", "limit": 42, "owner": "fixture Folder WebUI profile theme cases" }, { "file": "tests/browser/cases/import.mjs", "limit": 74, "owner": "fixture import and export cases" } ], "intent": { @@ -40,21 +47,25 @@ "tests/browser/cases/docker.mjs", "tests/browser/cases/dashboard-lifecycle.mjs", "tests/browser/cases/runtime-interactions.mjs", + "tests/browser/cases/docker-hidden-folders.mjs", "tests/browser/cases/settings.mjs", "tests/browser/cases/settings-start-order.mjs", "tests/browser/cases/folder-editor.mjs", + "tests/browser/cases/folder-webui-profiles.mjs", + "tests/browser/cases/folder-webui-profile-theme.mjs", "tests/browser/cases/import.mjs" ], "supportFiles": [ "scripts/fixture_browser_tests.mjs", "scripts/lib/fixture-browser-server.mjs", "scripts/lib/fixture-browser-runner.mjs", - "tests/browser/helpers/docker-preview-context.mjs" + "tests/browser/helpers/docker-preview-context.mjs", + "tests/browser/helpers/docker-hidden-folder-context.mjs" ], - "testCount": 43, - "assertionCount": 557, - "pageEvaluateCount": 109, - "orderedTitleSha256": "491369c2cae7533952fe8634ccef5185f1a713d57d2b440273973accb8d212aa" + "testCount": 49, + "assertionCount": 617, + "pageEvaluateCount": 124, + "orderedTitleSha256": "8d5719c0e640edebabad9e8721447f53f85350e8555b26a4e890cfdab1a5a804" } } } diff --git a/scripts/workflow_self_check.sh b/scripts/workflow_self_check.sh index 51e50d5f..c0334400 100644 --- a/scripts/workflow_self_check.sh +++ b/scripts/workflow_self_check.sh @@ -41,6 +41,7 @@ for (const relativePath of [ 'scripts/run_ci_suite.sh', 'scripts/actionlint_guard.sh', 'scripts/classify_ci_changes.mjs', + 'scripts/issue_form_guard.mjs', 'scripts/csp_readiness_guard.mjs', 'scripts/fixture_browser_tests.sh', 'scripts/fixture_browser_tests.mjs', @@ -88,6 +89,9 @@ if (!/detect-changes:/.test(ciWorkflow)) { if (!/quality:/.test(ciWorkflow)) { fail('CI workflow must define a quality summary job.'); } +if (!/^ guard-suite:\s*$[\s\S]*?^ name:\s*CI tests and guards\s*$/m.test(ciWorkflow)) { + fail('CI guard-suite must expose an always-present test signal recognized by repository quality scanners.'); +} if (/dorny\/paths-filter@/.test(ciWorkflow)) { fail('CI workflow must use the repository-owned change classifier instead of dorny/paths-filter.'); } @@ -119,6 +123,13 @@ if (!/runtime_performance_benchmarks\.sh/.test(read('scripts/run_ci_suite.sh'))) if (!/test_runner_contract_guard\.mjs/.test(read('scripts/run_ci_suite.sh'))) { fail('The shared lint lane must enforce split test-runner contracts.'); } +if (!/run_timed_step issue-form-contract/.test(read('scripts/run_ci_suite.sh'))) { + fail('Workflow and full guard lanes must enforce issue-form contracts.'); +} +if (!/'\.github\/ISSUE_TEMPLATE\/\*\*'/.test(read('scripts/classify_ci_changes.mjs')) + || !/'scripts\/issue_form_guard\.mjs'/.test(read('scripts/classify_ci_changes.mjs'))) { + fail('Issue forms and their guard must be classified as workflow changes.'); +} if (!/tmp\/fixture-browser-artifacts/.test(ciWorkflow)) { fail('CI workflow must retain deterministic fixture browser artifacts.'); } @@ -173,8 +184,9 @@ for (const [name, workflow] of [ if (!/bash scripts\/build_release_notes\.sh/.test(releaseOnMainWorkflow)) { fail('Release On Main workflow must build release notes via scripts/build_release_notes.sh.'); } -if (!/permissions:\s*\n\s*contents:\s*write\s*\n\s*id-token:\s*write\s*\n\s*attestations:\s*write/.test(releaseOnMainWorkflow)) { - fail('Release On Main must grant only the release, OIDC, and attestation permissions required for signed provenance.'); +if (!/^permissions:\s*\n contents:\s*read\s*$/m.test(releaseOnMainWorkflow) + || !/permissions:\s*\n\s*contents:\s*write\s*\n\s*id-token:\s*write\s*\n\s*attestations:\s*write/.test(jobBlock(releaseOnMainWorkflow, 'release'))) { + fail('Release On Main must keep top-level access read-only and scope release, OIDC, and attestation writes to its release job.'); } if ((codeqlWorkflow.match(/github\/codeql-action\/(?:init|autobuild|analyze)@[0-9a-f]{40}\s+# v4/g) || []).length !== 3) { fail('CodeQL must use commit-pinned v4 init, autobuild, and analyze actions.'); @@ -185,6 +197,10 @@ if (!/node scripts\/codeql_alert_guard\.mjs --commit-sha/.test(codeqlWorkflow)) if (!/schedule:/.test(codeqlWorkflow) || !/workflow_dispatch:/.test(codeqlWorkflow)) { fail('CodeQL must run on schedule and support manual recovery checks.'); } +if (!/^permissions:\s*\n actions:\s*read\s*\n contents:\s*read\s*$/m.test(codeqlWorkflow) + || !/permissions:\s*\n\s*actions:\s*read\s*\n\s*contents:\s*read\s*\n\s*security-events:\s*write/.test(jobBlock(codeqlWorkflow, 'analyze'))) { + fail('CodeQL must keep top-level access read-only and scope security-events write to the analyze job.'); +} if (!/actions\/dependency-review-action@[0-9a-f]{40}\s+# v5/.test(dependencyReviewWorkflow) || !/fail-on-severity:\s*high/.test(dependencyReviewWorkflow) || !/license-check:\s*true/.test(dependencyReviewWorkflow) @@ -260,8 +276,9 @@ if (!/bash scripts\/prepare_backmerge_dev_package\.sh/.test(backmergeWorkflow) | /FVPLUS_ALLOW_PACKAGED_SOURCE_DRIFT:\s*'1'/.test(backmergeWorkflow)) { fail('Back-merge workflow must package merged source instead of bypassing packaged/source drift validation.'); } -if (!/pull-requests:\s*write/.test(backmergeWorkflow)) { - fail('Back-merge workflow must have pull-requests: write permission.'); +if (!/^permissions:\s*\n contents:\s*read\s*$/m.test(backmergeWorkflow) + || !/permissions:\s*\n\s*contents:\s*write\s*\n\s*pull-requests:\s*write/.test(jobBlock(backmergeWorkflow, 'backmerge'))) { + fail('Back-merge workflow must keep top-level access read-only and scope contents and pull-request writes to the backmerge job.'); } if (!/Create or update back-merge PR/.test(backmergeWorkflow) || !/gh api --method POST/.test(backmergeWorkflow) || @@ -330,15 +347,20 @@ if (!/FVPLUS_FIXTURE_BROWSERS:\s*chromium,firefox,webkit/.test(scheduledValidati if (/FVPLUS_UNRAID_MATRIX|FVPLUS_BROWSER_SMOKE_URL|FVPLUS_THEME_MATRIX_URLS|live-unraid:|gh issue/.test(scheduledValidationWorkflow)) { fail('Scheduled validation must not depend on live-Unraid targets, secrets, or issue automation.'); } +const cloneTrafficCollectJob = jobBlock(cloneTrafficBadgeWorkflow, 'collect'); +const cloneTrafficPublishJob = jobBlock(cloneTrafficBadgeWorkflow, 'publish'); if (!/schedule:/.test(cloneTrafficBadgeWorkflow) || !/workflow_dispatch:/.test(cloneTrafficBadgeWorkflow) - || !/permissions:\s*\n\s*contents:\s*write/.test(cloneTrafficBadgeWorkflow) - || !/secrets\.FVPLUS_TRAFFIC_TOKEN/.test(cloneTrafficBadgeWorkflow) - || !/repos\/\$\{GITHUB_REPOSITORY\}\/traffic\/clones/.test(cloneTrafficBadgeWorkflow) - || !/github\.token/.test(cloneTrafficBadgeWorkflow) + || !/^permissions:\s*\n contents:\s*read\s*$/m.test(cloneTrafficBadgeWorkflow) + || !/secrets\.FVPLUS_TRAFFIC_TOKEN/.test(cloneTrafficCollectJob) + || /github\.token/.test(cloneTrafficCollectJob) + || !/repos\/\$\{GITHUB_REPOSITORY\}\/traffic\/clones/.test(cloneTrafficCollectJob) + || !/permissions:\s*\n\s*contents:\s*write/.test(cloneTrafficPublishJob) + || !/github\.token/.test(cloneTrafficPublishJob) + || /secrets\.FVPLUS_TRAFFIC_TOKEN/.test(cloneTrafficPublishJob) || !/--branch metrics/.test(cloneTrafficBadgeWorkflow) || !/Total clones \\u00b7 14d/.test(cloneTrafficBadgeWorkflow)) { - fail('Clone traffic badge workflow must publish the authenticated rolling 14-day total to the isolated metrics branch.'); + fail('Clone traffic badge workflow must isolate authenticated collection from the write-scoped metrics publisher.'); } if (!/schedule:/.test(scheduledWorkflowHealthWorkflow) || !/workflow_dispatch:/.test(scheduledWorkflowHealthWorkflow) @@ -361,7 +383,7 @@ for (const [workflowName, workflow, jobNames] of [ ['dependency-review', dependencyReviewWorkflow, ['dependency-review']], ['dependency-vulnerability-scan', dependencyVulnerabilityScanWorkflow, ['scan']], ['scorecard', scorecardWorkflow, ['analysis']], - ['clone-traffic-badge', cloneTrafficBadgeWorkflow, ['refresh']], + ['clone-traffic-badge', cloneTrafficBadgeWorkflow, ['collect', 'publish']], ['scheduled-validation', scheduledValidationWorkflow, ['cross-browser-fixtures']], ['scheduled-workflow-health', scheduledWorkflowHealthWorkflow, ['watchdog']], ['unraid-compatibility-monitor', upstreamMonitorWorkflow, ['monitor', 'php-runtime-compatibility']] diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page index 4a9a52d0..9ed132f3 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/Folder.page @@ -127,6 +127,7 @@ try { + @@ -898,6 +899,10 @@ try { Stopped + `; - const buildViewMenuHtml = (currentMode) => VIEW_OPTIONS.map((option) => ` + const buildViewMenuHtml = (currentMode) => { + const hiddenState = getHiddenVisibilitySummary(); + const hiddenCount = Math.max(0, Number(hiddenState?.explicitCount) || 0); + const revealHidden = hiddenState?.revealHiddenFolders === true; + const modeOptions = VIEW_OPTIONS.map((option) => ` `).join(''); + const hiddenLabel = `${translate('docker.visibility.hidden-folders', 'Hidden folders')} (${hiddenCount})`; + return `${modeOptions} + + + `; + }; const buildToolsMenuHtml = () => { const hideEmpty = utils.normalizePrefs(getPrefs() || {}).hideEmptyFolders === true; @@ -259,6 +291,12 @@ const hideEmpty = utils.normalizePrefs(getPrefs() || {}).hideEmptyFolders === true; if (reconcileFilterWithPrefs()) applyFilterState(); const state = summarize(); + const hiddenState = getHiddenVisibilitySummary(); + const allFoldersHidden = isFolderView + && hiddenState?.revealHiddenFolders !== true + && Number(hiddenState?.totalFolderCount || 0) > 0 + && Number(hiddenState?.effectiveCount || 0) >= Number(hiddenState?.totalFolderCount || 0); + const undoActive = !!hiddenFolderUndo && Number(hiddenFolderUndo.expiresAt || 0) > Date.now(); const folderControls = isFolderView ? ` ${buildActionButtonHtml({ action: 'add-folder', label: translate('docker.actions.add-folder', 'Add Folder'), icon: 'fa-plus' })} ${buildActionButtonHtml({ action: 'expand-all', label: translate('docker.actions.expand-all', 'Expand All'), icon: 'fa-expand' })} @@ -287,7 +325,18 @@ ${buildToolsMenuHtml()} - `; + + ${undoActive ? `
+ ${svgIcon('eye-off', { className: 'fvplus-docker-visibility-notice-icon' })} + ${escapeHtml(translate('docker.visibility.hidden-title', 'Folder hidden'))} ${escapeHtml(String(hiddenFolderUndo.folderName || translate('docker.visibility.folder-fallback', 'Folder')))} ${escapeHtml(translate('docker.visibility.hidden-message', 'was hidden from the Docker view.'))} + +
` : ''} + ${allFoldersHidden ? `
+ ${svgIcon('eye-off', { className: 'fvplus-docker-visibility-notice-icon' })} + ${escapeHtml(translate('docker.visibility.all-hidden-title', 'All folder rows are hidden.'))} ${escapeHtml(translate('docker.visibility.all-hidden-help', 'Reveal them temporarily or restore every hidden folder.'))} + + +
` : ''}`; Array.from(bar.querySelectorAll('button')).forEach((button) => { button.disabled = button.disabled || busy; }); @@ -408,10 +457,39 @@ actionMenuOpen = ''; folderFilterMode = 'all'; clearFocusedFolder(); + setRevealHiddenFolders(false); applyFilterState(); sync(); }; + const clearHiddenFolderUndo = (folderId = '') => { + const expectedId = String(folderId || '').trim(); + if (expectedId && String(hiddenFolderUndo?.folderId || '') !== expectedId) return false; + if (hiddenFolderUndoTimer) { + win?.clearTimeout?.(hiddenFolderUndoTimer); + hiddenFolderUndoTimer = 0; + } + hiddenFolderUndo = null; + return true; + }; + + const offerHiddenFolderUndo = ({ folderId = '', folderName = '' } = {}) => { + clearHiddenFolderUndo(); + const id = String(folderId || '').trim(); + if (!id) return; + hiddenFolderUndo = { + folderId: id, + folderName: String(folderName || translate('docker.visibility.folder-fallback', 'Folder')), + expiresAt: Date.now() + 10000 + }; + hiddenFolderUndoTimer = win?.setTimeout?.(() => { + hiddenFolderUndoTimer = 0; + hiddenFolderUndo = null; + sync(); + }, 10000) || 0; + sync(); + }; + const getRootFolderIds = () => { const folders = getFolders() || {}; const hierarchy = buildFolderHierarchy(folders); @@ -443,6 +521,37 @@ if (SETTINGS_ROUTES[route] && win?.location) win.location.href = SETTINGS_ROUTES[route]; return; } + const hiddenButton = target.closest?.('[data-fvplus-docker-hidden]'); + if (hiddenButton && !hiddenButton.disabled) { + event.preventDefault(); + const action = String(hiddenButton.getAttribute('data-fvplus-docker-hidden') || ''); + actionMenuOpen = ''; + if (action === 'toggle-reveal') { + folderFilterMode = 'all'; + clearFocusedFolder(); + const current = getHiddenVisibilitySummary()?.revealHiddenFolders === true; + setRevealHiddenFolders(!current); + applyFilterState(); + sync(); + } + if (action === 'restore-all') { + runTask(async () => { + await restoreAllHiddenFolders(); + clearHiddenFolderUndo(); + setRevealHiddenFolders(false); + applyFilterState(); + }); + } + if (action === 'undo' && hiddenFolderUndo?.folderId) { + const undoId = hiddenFolderUndo.folderId; + clearHiddenFolderUndo(undoId); + runTask(async () => { + await restoreHiddenFolder(undoId); + applyFilterState(); + }); + } + return; + } const toolButton = target.closest?.('[data-fvplus-docker-tool]'); if (toolButton && !toolButton.disabled) { event.preventDefault(); @@ -540,7 +649,9 @@ setFilterMode: setFolderFilter, setPageViewMode, resetView, - getFilterMode: () => folderFilterMode + getFilterMode: () => folderFilterMode, + offerHiddenFolderUndo, + clearHiddenFolderUndo }); }; diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.actions.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.actions.js index 1fd43702..fa1cfce6 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.actions.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.actions.js @@ -61,7 +61,7 @@ const getDockerMenuLabel = typeof deps.getDockerMenuLabel === 'function' ? deps.getDockerMenuLabel : ((_key, fallback) => String(fallback || _key || '').trim()); - const requestClient = deps.requestClient || win?.FolderViewPlusRequest || null; + const webuiProfiles = deps.webuiProfiles || win?.FolderViewPlusFoundationModules?.folderWebuiProfiles || null, requestClient = deps.requestClient || win?.FolderViewPlusRequest || null; const folderSettingsTransfer = deps.folderSettingsTransfer || (typeof win?.FolderViewPlusFolderSettingsTransfer?.createApi === 'function' ? win.FolderViewPlusFolderSettingsTransfer.createApi({ window: win }) @@ -518,7 +518,6 @@ // Editor prefill is best-effort only. } }; - const buildDockerFolderEditorUrl = (id = '', options = {}) => { const params = new URLSearchParams(); const hashParams = new URLSearchParams(); @@ -533,23 +532,23 @@ params.set('parentId', parentId); hashParams.set('parentId', parentId); } + const section = String(options?.section || '').trim(); + if (section === 'webuiProfiles') { params.set('section', section); hashParams.set('section', section); } params.set('_', String(Date.now())); return `/Docker/Folder?${params.toString()}#${hashParams.toString()}`; }; - - const editFolder = (id) => { + const editFolder = (id, options = {}) => { if (!ensureDockerFolderUnlocked(id, 'Edit folder')) { return; } debugLog(`[FV3_DEBUG] editFolder (id: ${id}): Redirecting to edit page.`); seedFolderEditorPrefill('docker', id); - const targetUrl = buildDockerFolderEditorUrl(id); + const targetUrl = buildDockerFolderEditorUrl(id, options); recordFolderEditorLaunchDebug('docker', 'docker', id, targetUrl); if (win?.location) { win.location.href = targetUrl; } }; - const createChildFolder = (parentId) => { const safeParentId = normalizeFolderParentId(parentId); if (!safeParentId || !getFolderById(safeParentId)) { @@ -617,7 +616,7 @@ return out; }, []); - const showFolderWebuiPopupWarning = (openedCount, totalCount, blockedUrls) => { + const showFolderWebuiPopupWarning = (openedCount, totalCount, blockedUrls, retryLabel = 'Open all WebUIs') => { const blockedList = Array.isArray(blockedUrls) ? blockedUrls.slice(0, 6) : []; const linkHtml = blockedList .map((url) => `
  • ${escapeHtml(url)}
  • `) @@ -644,7 +643,7 @@ `
      `, `
    1. Click the popup-blocked icon in your browser address bar.
    2. `, `
    3. Choose to always allow popups/redirects for this Unraid host.
    4. `, - `
    5. Run Open all WebUIs again.
    6. `, + `
    7. Run ${escapeHtml(retryLabel)} again.
    8. `, `
    `, ``, `
    `, @@ -671,12 +670,16 @@ confirmButtonText: 'Got it' }); }; - - const openFolderWebuisFromMenu = (id, runningOnly = true, includeDescendants = false) => { + const openFolderWebuisFromMenu = (id, runningOnly = true, includeDescendants = false, profileId = '') => { runDockerGuardedAction('open-folder-webuis', async () => { hideAllTips(); - const urls = Array.from(new Set(collectFolderWebuiTargets(id, includeDescendants, runningOnly))); + const profileResult = profileId && typeof webuiProfiles?.resolveProfileLaunch === 'function' ? webuiProfiles.resolveProfileLaunch( + getFolderById(id)?.settings, profileId, getScopedRuntimeContainersForFolder(id, false), { runningOnly, getSafeWebuiUrl } + ) : null; + const profile = profileResult?.profile || null; + const urls = Array.from(new Set(profileId ? (profileResult?.urls || []) : collectFolderWebuiTargets(id, includeDescendants, runningOnly))); if (!urls.length) { + if (profile) swalFn({ title: getDockerMenuLabel('docker.webui-profiles.none-title', 'No WebUIs ready'), text: getDockerMenuLabel('docker.webui-profiles.none-body', 'None of the selected containers are running with an available WebUI.'), type: 'info' }); return; } const blocked = []; @@ -691,13 +694,10 @@ } } if (blocked.length > 0) { - showFolderWebuiPopupWarning(urls.length - blocked.length, urls.length, blocked); + showFolderWebuiPopupWarning(urls.length - blocked.length, urls.length, blocked, profile?.name || 'Open all WebUIs'); } - }, { - userVisible: false - }); + }, { userVisible: false }); }; - const buildDockerFolderClonePayload = (source, overrides = {}) => { const sourceName = String(source?.name || '').trim() || 'Folder'; const sourceParentId = normalizeFolderParentId(source?.parentId || source?.parent_id || ''); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.child-folder-preview-menu.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.child-folder-preview-menu.js new file mode 100644 index 00000000..fca46f54 --- /dev/null +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.child-folder-preview-menu.js @@ -0,0 +1,131 @@ +// @ts-check +(function(root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + return; + } + root.FolderViewPlusFoundationModules = root.FolderViewPlusFoundationModules || {}; + root.FolderViewPlusFoundationModules.dockerChildFolderPreviewMenu = factory(); +}(typeof globalThis !== 'undefined' ? globalThis : this, function() { + const fallbackWindow = typeof globalThis !== 'undefined' + ? globalThis + : (typeof window !== 'undefined' ? window : null); + + const createApi = (deps = {}) => { + const win = deps.window || fallbackWindow; + const jq = deps.$ || win?.jQuery || win?.$; + const expandFolderPathToChild = typeof deps.expandFolderPathToChild === 'function' + ? deps.expandFolderPathToChild + : (() => {}); + const scrollFolderRowIntoView = typeof deps.scrollFolderRowIntoView === 'function' + ? deps.scrollFolderRowIntoView + : (() => {}); + const editFolder = typeof deps.editFolder === 'function' ? deps.editFolder : (() => {}); + const openFolderActions = typeof deps.openFolderActions === 'function' ? deps.openFolderActions : (() => {}); + const recordMenuOpen = typeof deps.recordMenuOpen === 'function' ? deps.recordMenuOpen : (() => {}); + + const resolveInputMethod = (event = null) => { + const sourceEvent = event?.originalEvent || event || {}; + if (event?.type === 'keydown' || sourceEvent.type === 'keydown') return 'keyboard'; + if (String(sourceEvent.pointerType || '').toLowerCase() === 'touch' + || Number(sourceEvent.touches?.length || 0) > 0 + || Number(sourceEvent.changedTouches?.length || 0) > 0) { + return 'touch'; + } + if (event?.type === 'contextmenu' || sourceEvent.type === 'contextmenu') return 'contextmenu'; + return event ? 'mouse' : 'unknown'; + }; + + const resolveActivationPoint = (event = null, $item = null) => { + const sourceEvent = event?.originalEvent || event || {}; + const touchPoint = sourceEvent.changedTouches?.[0] || sourceEvent.touches?.[0] || null; + const clientX = Number(touchPoint?.clientX ?? sourceEvent.clientX); + const clientY = Number(touchPoint?.clientY ?? sourceEvent.clientY); + const keyboardEvent = event?.type === 'keydown' || sourceEvent.type === 'keydown'; + if (!keyboardEvent && Number.isFinite(clientX) && Number.isFinite(clientY) && (clientX !== 0 || clientY !== 0)) { + return { clientX, clientY }; + } + const itemNode = $item?.get?.(0) || null; + const rect = typeof itemNode?.getBoundingClientRect === 'function' + ? itemNode.getBoundingClientRect() + : null; + return { + clientX: rect ? rect.left + Math.max(1, rect.width / 2) : 0, + clientY: rect ? rect.top + Math.max(1, rect.height / 2) : 0 + }; + }; + + const close = () => { + if (!jq) return; + jq('.fv-folder-preview-context-menu').remove(); + const doc = win?.document || (typeof document !== 'undefined' ? document : null); + if (doc) jq(doc).off('click.fvFolderPreviewContext keydown.fvFolderPreviewContext'); + }; + + const show = (options = {}) => { + const event = options.event || null; + const inputMethod = resolveInputMethod(event); + if (!jq) { + recordMenuOpen({ success: false, inputMethod, reason: 'document-unavailable' }); + return false; + } + close(); + const doc = win?.document || (typeof document !== 'undefined' ? document : null); + if (!doc?.body) { + recordMenuOpen({ success: false, inputMethod, reason: 'document-unavailable' }); + return false; + } + const rootId = String(options.rootId || '').trim(); + const childId = String(options.childId || '').trim(); + const safeChildName = String(options.childName || 'Folder').trim() || 'Folder'; + const $menu = jq(''); + const addAction = (label, iconClass, onClick) => { + const $button = jq(''); + $button.append(jq(``)); + $button.append(jq('').text(label)); + $button.on('click', (clickEvent) => { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + close(); + onClick(); + }); + $menu.append($button); + }; + addAction('Expand to folder', 'fa-level-down', () => { + expandFolderPathToChild(rootId, childId); + scrollFolderRowIntoView(childId); + }); + addAction('Edit folder', 'fa-pencil', () => editFolder(childId)); + addAction('Open folder actions', 'fa-bars', () => { + expandFolderPathToChild(rootId, childId); + scrollFolderRowIntoView(childId); + openFolderActions(childId); + }); + $menu.attr('aria-label', `${safeChildName} folder actions`); + jq(doc.body).append($menu); + const viewportWidth = Number(win?.innerWidth || doc.documentElement?.clientWidth || 0); + const viewportHeight = Number(win?.innerHeight || doc.documentElement?.clientHeight || 0); + const menuNode = $menu.get(0); + const menuWidth = Number(menuNode?.offsetWidth || 180); + const menuHeight = Number(menuNode?.offsetHeight || 112); + const activationPoint = resolveActivationPoint(event, options.$item || null); + const left = Math.max(8, Math.min(activationPoint.clientX, viewportWidth ? viewportWidth - menuWidth - 8 : activationPoint.clientX)); + const top = Math.max(8, Math.min(activationPoint.clientY, viewportHeight ? viewportHeight - menuHeight - 8 : activationPoint.clientY)); + $menu.css({ left: `${left}px`, top: `${top}px` }); + recordMenuOpen({ success: true, inputMethod }); + setTimeout(() => { + jq(doc) + .on('click.fvFolderPreviewContext', close) + .on('keydown.fvFolderPreviewContext', (keyEvent) => { + if (keyEvent.key === 'Escape') close(); + }); + if (inputMethod === 'keyboard') $menu.find('button').first().trigger('focus'); + }, 0); + return true; + }; + + return Object.freeze({ close, show }); + }; + + return Object.freeze({ createApi }); +})); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hidden-folders.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hidden-folders.js new file mode 100644 index 00000000..e43087e6 --- /dev/null +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hidden-folders.js @@ -0,0 +1,258 @@ +(function(root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + return; + } + const modules = root.FolderViewPlusFoundationModules = root.FolderViewPlusFoundationModules || {}; + modules.dockerHiddenFolders = factory(); +}(typeof globalThis !== 'undefined' ? globalThis : this, function() { + const QUICK_LABELS = Object.freeze(['hide folder', 'restore folder', 'restore hidden branch']); + const QUICK_ICON_CLASSES = Object.freeze(['fa-eye', 'fa-eye-slash']); + const createApi = (deps = {}) => { + const win = deps.window || (typeof window !== 'undefined' ? window : null); + const doc = deps.document || win?.document || null; + const $ = deps.$; + const getFolders = typeof deps.getFolders === 'function' ? deps.getFolders : (() => ({})); + const getPrefs = typeof deps.getPrefs === 'function' ? deps.getPrefs : (() => ({})); + const setPrefs = typeof deps.setPrefs === 'function' ? deps.setPrefs : (() => {}); + const normalizePrefs = typeof deps.normalizePrefs === 'function' ? deps.normalizePrefs : ((value) => value || {}); + const normalizeParentId = typeof deps.normalizeParentId === 'function' ? deps.normalizeParentId : ((value) => String(value || '').trim()); + const readFolderIdFromRow = typeof deps.readFolderIdFromRow === 'function' ? deps.readFolderIdFromRow : (() => ''); + const readFolderOwnerFromRow = typeof deps.readFolderOwnerFromRow === 'function' ? deps.readFolderOwnerFromRow : (() => ''); + const runtimeStateStore = deps.runtimeStateStore || { set: () => {} }; + const safeActionRunner = deps.safeActionRunner || { run: async (_key, action) => ({ ok: true, value: await action({ isLatest: () => true }) }) }; + const runGuardedAction = typeof deps.runGuardedAction === 'function' + ? deps.runGuardedAction + : (async (_name, action) => { + try { return { ok: true, value: await action() }; } catch (error) { return { ok: false, error }; } + }); + const savePrefs = typeof deps.savePrefs === 'function' ? deps.savePrefs : (async (_patch, current) => current || {}); + const fetchPrefs = typeof deps.fetchPrefs === 'function' ? deps.fetchPrefs : (async () => getPrefs()); + const syncDependentUi = typeof deps.syncDependentUi === 'function' ? deps.syncDependentUi : (() => {}); + const getFocusedFolderId = typeof deps.getFocusedFolderId === 'function' ? deps.getFocusedFolderId : (() => ''); + const clearFocusedFolder = typeof deps.clearFocusedFolder === 'function' ? deps.clearFocusedFolder : (() => {}); + const offerUndo = typeof deps.offerUndo === 'function' ? deps.offerUndo : (() => {}); + const dismissUndo = typeof deps.dismissUndo === 'function' ? deps.dismissUndo : (() => false); + const translate = typeof deps.translate === 'function' ? deps.translate : ((_key, fallback) => fallback || ''); + const escapeHtml = typeof deps.escapeHtml === 'function' ? deps.escapeHtml : ((value) => String(value || '')); + const svgIcon = typeof deps.svgIcon === 'function' ? deps.svgIcon : (() => ''); + let revealHiddenFolders = false; + let pendingIdsOverride = null; + + const normalizeIds = (value) => Array.isArray(value) + ? Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) + : []; + const getExplicitIds = () => normalizeIds(getPrefs()?.hiddenFolderIds); + const reconcilePrefs = (prefs = {}) => { + const normalized = normalizePrefs(prefs || {}); + if (!pendingIdsOverride || Date.now() > pendingIdsOverride.expiresAt) { + pendingIdsOverride = null; + return normalized; + } + return normalizePrefs({ ...normalized, hiddenFolderIds: pendingIdsOverride.ids }); + }; + const getOwnerId = (folderId) => { + const id = String(folderId || '').trim(); + if (!id) return ''; + const explicitHidden = new Set(getExplicitIds()); + const folders = getFolders(); + let cursor = id; + const visited = new Set(); + while (cursor && !visited.has(cursor)) { + if (explicitHidden.has(cursor)) return cursor; + visited.add(cursor); + const folder = folders?.[cursor] || {}; + cursor = normalizeParentId(folder?.parentId || folder?.parent_id || ''); + } + return ''; + }; + const getEffectiveIds = () => { + const hidden = new Set(); + for (const id of Object.keys(getFolders() || {})) { + if (getOwnerId(id)) hidden.add(id); + } + return hidden; + }; + const getSummary = () => { + const folders = getFolders() || {}; + const explicitIds = getExplicitIds().filter((id) => !!folders[id]); + const effectiveIds = Array.from(getEffectiveIds()); + return { + explicitIds, + effectiveIds, + explicitCount: explicitIds.length, + effectiveCount: effectiveIds.length, + totalFolderCount: Object.keys(folders).length, + revealHiddenFolders + }; + }; + const syncIndicator = ($row, hidden) => { + if (!$row?.length || !$row.hasClass('folder')) return; + const $existing = $row.find('.fv-folder-hidden-indicator').first(); + if (!hidden || !revealHiddenFolders) { + $existing.remove(); + return; + } + if ($existing.length) return; + const label = translate('docker.folder.hidden-indicator', 'Hidden'); + const icon = svgIcon('eye-off', { className: 'fv-folder-hidden-indicator-icon' }) + || ''; + const $indicator = $(`${icon}${escapeHtml(label)}`); + const $name = $row.find('.folder-appname').first(); + if ($name.length) $name.after($indicator); + }; + const applyVisibility = () => { + const effectiveHidden = getEffectiveIds(); + const summary = getSummary(); + $('body').toggleClass('fvplus-docker-reveal-hidden', revealHiddenFolders); + doc?.body?.setAttribute?.('data-fvplus-docker-hidden-explicit', String(summary.explicitCount)); + doc?.body?.setAttribute?.('data-fvplus-docker-hidden-effective', String(effectiveHidden.size)); + doc?.body?.setAttribute?.('data-fvplus-docker-hidden-reveal', revealHiddenFolders ? 'true' : 'false'); + $('#docker_list > tr').each((_, row) => { + if (!row) return; + const folderId = readFolderIdFromRow(row); + const ownerId = folderId || readFolderOwnerFromRow(row); + const hidden = !!ownerId && effectiveHidden.has(ownerId); + const $row = $(row); + $row.toggleClass('fv-folder-user-hidden', hidden); + $row.toggleClass('fv-folder-hidden-revealed', hidden && revealHiddenFolders); + if (folderId) syncIndicator($row, hidden); + }); + runtimeStateStore.set({ hiddenFolderIds: getExplicitIds(), revealHiddenFolders }); + return summary; + }; + const applyIds = (nextHiddenIds) => { + const hiddenFolderIds = normalizeIds(nextHiddenIds); + setPrefs(normalizePrefs({ ...(getPrefs() || {}), hiddenFolderIds })); + runtimeStateStore.set({ hiddenFolderIds }); + }; + const syncUi = () => { + applyVisibility(); + syncDependentUi(); + }; + const persistIds = async (nextHiddenIds) => { + const folders = getFolders() || {}; + const hiddenFolderIds = normalizeIds(nextHiddenIds).filter((id) => !!folders[id]); + const savedPrefs = await savePrefs({ hiddenFolderIds }, getPrefs() || {}); + return normalizeIds(savedPrefs?.hiddenFolderIds || hiddenFolderIds).filter((id) => !!folders[id]); + }; + const recoverLatestFailure = async (fallbackIds) => { + try { + applyIds((await fetchPrefs())?.hiddenFolderIds); + } catch (_error) { + applyIds(fallbackIds); + } + syncUi(); + }; + const updateFolder = async (folderId, requestedHidden, options = {}) => { + const id = String(folderId || '').trim(); + const folders = getFolders() || {}; + if (!id || !folders[id]) return false; + const previousIds = getExplicitIds(); + const hidden = requestedHidden === true; + const folderName = String(folders[id]?.name || translate('docker.folder.unnamed', 'Folder')); + const nextIds = hidden + ? (previousIds.includes(id) ? previousIds : [...previousIds, id]) + : previousIds.filter((entry) => entry !== id); + return safeActionRunner.run('docker-hidden-folders', async (intent) => { + const result = await runGuardedAction('toggle-folder-visibility', async () => { + const confirmedIds = await persistIds(nextIds); + if (!intent.isLatest()) return; + pendingIdsOverride = null; + applyIds(confirmedIds); + syncUi(); + }, { + userMessage: translate('docker.folder.visibility-failed', 'The folder visibility preference could not be saved.'), + userVisible: true + }); + if (!result.ok && intent.isLatest()) { + pendingIdsOverride = null; + if (hidden) dismissUndo(id); + await recoverLatestFailure(previousIds); + if (!hidden && previousIds.includes(id)) offerUndo({ folderId: id, folderName }); + } + return result.ok; + }, { + queueIfBusy: true, + onIntent: () => { + pendingIdsOverride = { ids: normalizeIds(nextIds), expiresAt: Date.now() + 10000 }; + applyIds(nextIds); + if (hidden && getOwnerId(getFocusedFolderId())) clearFocusedFolder(); + if (hidden && options.offerUndo !== false) offerUndo({ folderId: id, folderName }); + if (!hidden) dismissUndo(id); + syncUi(); + } + }); + }; + const hideFolder = (folderId) => updateFolder(folderId, true); + const restoreFolder = (folderId) => updateFolder(folderId, false, { offerUndo: false }); + const restoreAll = async () => { + const previousIds = getExplicitIds(); + if (!previousIds.length) return true; + return safeActionRunner.run('docker-hidden-folders', async (intent) => { + const result = await runGuardedAction('restore-hidden-folders', async () => { + const confirmedIds = await persistIds([]); + if (!intent.isLatest()) return; + pendingIdsOverride = null; + applyIds(confirmedIds); + revealHiddenFolders = false; + syncUi(); + }, { + userMessage: translate('docker.folder.visibility-failed', 'The folder visibility preference could not be saved.'), + userVisible: true + }); + if (!result.ok && intent.isLatest()) { + pendingIdsOverride = null; + await recoverLatestFailure(previousIds); + } + return result.ok; + }, { + queueIfBusy: true, + onIntent: () => { pendingIdsOverride = { ids: [], expiresAt: Date.now() + 10000 }; applyIds([]); dismissUndo(); syncUi(); } + }); + }; + const setReveal = (enabled) => { + revealHiddenFolders = enabled === true && getExplicitIds().length > 0; + applyVisibility(); + return revealHiddenFolders; + }; + const buildQuickAction = (folderId) => { + const id = String(folderId || '').trim(); + const hiddenOwnerId = getOwnerId(id); + const hiddenByAncestor = !!hiddenOwnerId && hiddenOwnerId !== id; + return { + text: hiddenOwnerId + ? (hiddenByAncestor + ? translate('docker.folder.restore-hidden-branch', 'Restore hidden branch') + : translate('docker.folder.restore', 'Restore folder')) + : translate('docker.folder.hide', 'Hide folder'), + icon: hiddenOwnerId ? 'fa-eye' : 'fa-eye-slash', + action: (event) => { + event?.preventDefault?.(); + void (hiddenOwnerId ? restoreFolder(hiddenOwnerId) : hideFolder(id)); + } + }; + }; + const decorateQuickIcon = (selectors, attempt = 0) => { + if (!$ || typeof svgIcon !== 'function') return false; + let decorated = false; + for (const selector of selectors || []) { + $(`${selector}:visible`).each((_, menu) => { + $(menu).children('li.fvplus-docker-quick-item').each((__, item) => { + const $item = $(item); + const $icon = $item.find('i.fa-eye, i.fa-eye-slash').first(); + if (!$icon.length || $item.find('svg[data-fv-icon="eye"], svg[data-fv-icon="eye-off"]').length) return; + $icon.replaceWith(svgIcon($icon.hasClass('fa-eye-slash') ? 'eye-off' : 'eye', { className: 'fvplus-docker-quick-svg' })); + decorated = true; + }); + }); + } + if (!decorated && attempt < 8) win?.setTimeout?.(() => decorateQuickIcon(selectors, attempt + 1), 18 * (attempt + 1)); + return decorated; + }; + + return Object.freeze({ normalizeIds, reconcilePrefs, getOwnerId, getSummary, applyVisibility, hideFolder, restoreFolder, restoreAll, setReveal, buildQuickAction, decorateQuickIcon, svgIcon }); + }; + + return Object.freeze({ createApi, QUICK_LABELS, QUICK_ICON_CLASSES }); +})); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js index cfdc4135..1f803fb9 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js @@ -116,16 +116,34 @@ runtimeCountLabel: Number(input.memberCount) > 0 ? `${Number(input.startedCount) || 0}/${Number(input.memberCount) || 0}` : 'Empty' }) }; + const childFolderPreviewMenuModule = deps.childFolderPreviewMenuModule + || win?.FolderViewPlusFoundationModules?.dockerChildFolderPreviewMenu + || null; const appendDockerPreviewActionButtons = typeof deps.appendDockerPreviewActionButtons === 'function' ? deps.appendDockerPreviewActionButtons : (() => {}); const decorateDockerPreviewMemberTriggers = typeof deps.decorateDockerPreviewMemberTriggers === 'function' ? deps.decorateDockerPreviewMemberTriggers : (() => {}); + const bindDockerNestedPreviewContext = typeof deps.bindDockerNestedPreviewContext === 'function' + ? deps.bindDockerNestedPreviewContext + : (() => false); + const auditDockerPreviewContextBridges = typeof deps.auditDockerPreviewContextBridges === 'function' + ? deps.auditDockerPreviewContextBridges + : (() => null); const getSafeWebuiUrl = typeof deps.getSafeWebuiUrl === 'function' ? deps.getSafeWebuiUrl : ((value) => String(value || '').trim()); const isCompactMultiRowPreview = typeof deps.isCompactMultiRowPreview === 'function' ? deps.isCompactMultiRowPreview : (() => false); const editFolder = typeof deps.editFolder === 'function' ? deps.editFolder : (() => {}); const openFolderActions = typeof deps.openFolderActions === 'function' ? deps.openFolderActions : (() => {}); + const recordChildFolderPreviewRender = typeof deps.recordChildFolderPreviewRender === 'function' + ? deps.recordChildFolderPreviewRender + : (() => {}); + const recordChildFolderPreviewBinding = typeof deps.recordChildFolderPreviewBinding === 'function' + ? deps.recordChildFolderPreviewBinding + : (() => {}); + const recordChildFolderPreviewMenuOpen = typeof deps.recordChildFolderPreviewMenuOpen === 'function' + ? deps.recordChildFolderPreviewMenuOpen + : (() => {}); const debugEnabled = deps.debugEnabled === true; const consoleRef = deps.console || win?.console || null; @@ -449,73 +467,21 @@ } }; - const closeFolderPreviewContextMenu = () => { - if (!jq) { - return; - } - jq('.fv-folder-preview-context-menu').remove(); - const doc = win?.document || (typeof document !== 'undefined' ? document : null); - if (doc) { - jq(doc).off('click.fvFolderPreviewContext keydown.fvFolderPreviewContext'); - } - }; - - const showFolderPreviewContextMenu = (event, rootId, childId, childName) => { - if (!jq) { - return; - } - closeFolderPreviewContextMenu(); - const doc = win?.document || (typeof document !== 'undefined' ? document : null); - if (!doc?.body) { - return; - } - const safeChildName = String(childName || 'Folder').trim() || 'Folder'; - const $menu = jq(''); - const addAction = (label, iconClass, onClick) => { - const $button = jq(''); - $button.append(jq(``)); - $button.append(jq('').text(label)); - $button.on('click', (clickEvent) => { - clickEvent.preventDefault(); - clickEvent.stopPropagation(); - closeFolderPreviewContextMenu(); - onClick(); - }); - $menu.append($button); - }; - addAction('Expand to folder', 'fa-level-down', () => { - expandFolderPathToChild(rootId, childId); - scrollFolderRowIntoView(childId); - }); - addAction('Edit folder', 'fa-pencil', () => editFolder(childId)); - addAction('Open folder actions', 'fa-bars', () => { - expandFolderPathToChild(rootId, childId); - scrollFolderRowIntoView(childId); - openFolderActions(childId); - }); - $menu.attr('aria-label', `${safeChildName} folder actions`); - jq(doc.body).append($menu); - const viewportWidth = Number(win?.innerWidth || doc.documentElement?.clientWidth || 0); - const viewportHeight = Number(win?.innerHeight || doc.documentElement?.clientHeight || 0); - const menuNode = $menu.get(0); - const menuWidth = Number(menuNode?.offsetWidth || 180); - const menuHeight = Number(menuNode?.offsetHeight || 112); - const left = Math.max(8, Math.min(Number(event?.clientX || 0), viewportWidth ? viewportWidth - menuWidth - 8 : Number(event?.clientX || 0))); - const top = Math.max(8, Math.min(Number(event?.clientY || 0), viewportHeight ? viewportHeight - menuHeight - 8 : Number(event?.clientY || 0))); - $menu.css({ left: `${left}px`, top: `${top}px` }); - setTimeout(() => { - jq(doc) - .on('click.fvFolderPreviewContext', closeFolderPreviewContextMenu) - .on('keydown.fvFolderPreviewContext', (keyEvent) => { - if (keyEvent.key === 'Escape') { - closeFolderPreviewContextMenu(); - } - }); - }, 0); - }; + const childFolderPreviewMenuApi = childFolderPreviewMenuModule + && typeof childFolderPreviewMenuModule.createApi === 'function' + ? childFolderPreviewMenuModule.createApi({ + window: win, + $: jq, + expandFolderPathToChild, + scrollFolderRowIntoView, + editFolder, + openFolderActions, + recordMenuOpen: recordChildFolderPreviewMenuOpen + }) + : null; const buildChildFolderPreviewItem = (parentId, childId, childFolder, options = {}) => { - const $item = jq(''); + const $item = jq(''); const $inner = jq(''); const $hand = jq(''); const runtimeContainers = buildRuntimeContainerMapForFolder(childId, true); @@ -549,20 +515,25 @@ $inner.append(jq('').text(model.name)); $inner.append(jq('').text(model.runtimeCountLabel)); $item.append($inner); - $item.on('click keydown', (event) => { + recordChildFolderPreviewRender(); + const openChildFolderPreviewMenu = (event) => { if (event.type === 'keydown' && !['Enter', ' '].includes(event.key)) { return; } event.preventDefault(); event.stopPropagation(); - expandFolderPathToChild(parentId, childId); - scrollFolderRowIntoView(childId); - }); - $item.on('contextmenu', (event) => { - event.preventDefault(); - event.stopPropagation(); - showFolderPreviewContextMenu(event, parentId, childId, model.name); - }); + childFolderPreviewMenuApi?.show?.({ + event, + rootId: parentId, + childId, + childName: model.name, + $item + }); + }; + $item + .on('click.fvChildFolderPreviewMenu keydown.fvChildFolderPreviewMenu', openChildFolderPreviewMenu) + .on('contextmenu.fvChildFolderPreviewMenu', openChildFolderPreviewMenu); + recordChildFolderPreviewBinding(); return $item; }; @@ -589,7 +560,7 @@ const allowLogsQuickAction = quickActionPrefs.preview_logs === true; $preview.empty(); for (const entry of entries) { - const { $item: item } = buildDockerPreviewItem({ + const { $item: item, $tooltipTrigger } = buildDockerPreviewItem({ entry, settings: folder?.settings || {}, autostart: entry?.autostart === true @@ -614,6 +585,7 @@ containerName ); $preview.append(item); + bindDockerNestedPreviewContext(item, $tooltipTrigger, entry, folder, id); } if (includeChildFolders) { const folders = getGlobalFolders(); @@ -630,6 +602,7 @@ $preview.children('span').wrap('
    '); applyFolderPreviewLayout($preview, folder?.settings || {}); layoutFolderPreviewRows($preview, folder?.settings || {}); + auditDockerPreviewContextBridges($preview, folder?.settings || {}); $preview.find('span.inner > span.appname').css('width', folder?.settings?.preview_text_width || ''); }; diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js index 90414d42..2e2f8782 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js @@ -19,6 +19,10 @@ 'eligibleTargetsAudited', 'boundTargetsAudited', 'missingBridgeTargets', 'handlerIntegrityFailures', 'dispatchAttempts', 'dispatchSuccesses', 'dispatchFailures', 'quickActionBypasses', 'storageWriteFailures' ]); + const CHILD_FOLDER_PREVIEW_COUNTER_KEYS = Object.freeze([ + 'chipsRendered', 'bindings', 'menuOpenAttempts', 'menuOpens', 'menuOpenFailures' + ]); + const CHILD_FOLDER_PREVIEW_INPUT_METHODS = Object.freeze(['mouse', 'keyboard', 'contextmenu', 'touch', 'unknown']); const PREVIEW_CONTEXT_FAILURE_REASONS = Object.freeze([ 'item-missing', 'native-trigger-missing', 'keyboard-target-missing', 'bridge-missing', 'handler-missing', 'native-event-unavailable', 'dispatch-failed' @@ -55,6 +59,11 @@ triggerSources: { icon: 0, name: 0, status: 0, card: 0, unknown: 0 }, inputMethods: { mouse: 0, keyboard: 0, unknown: 0 }, failureReasons: Object.fromEntries(PREVIEW_CONTEXT_FAILURE_REASONS.map((key) => [key, 0])), + childFolderPreview: { + counters: Object.fromEntries(CHILD_FOLDER_PREVIEW_COUNTER_KEYS.map((key) => [key, 0])), + inputMethods: Object.fromEntries(CHILD_FOLDER_PREVIEW_INPUT_METHODS.map((key) => [key, 0])), + lastEvent: null + }, lastEvent: null }); const createPreviewContextDiagnostics = (win) => { @@ -77,6 +86,24 @@ Object.keys(state.triggerSources).forEach((key) => { state.triggerSources[key] = capPreviewContextCount(stored.triggerSources?.[key]); }); Object.keys(state.inputMethods).forEach((key) => { state.inputMethods[key] = capPreviewContextCount(stored.inputMethods?.[key]); }); PREVIEW_CONTEXT_FAILURE_REASONS.forEach((key) => { state.failureReasons[key] = capPreviewContextCount(stored.failureReasons?.[key]); }); + CHILD_FOLDER_PREVIEW_COUNTER_KEYS.forEach((key) => { + state.childFolderPreview.counters[key] = capPreviewContextCount(stored.childFolderPreview?.counters?.[key]); + }); + CHILD_FOLDER_PREVIEW_INPUT_METHODS.forEach((key) => { + state.childFolderPreview.inputMethods[key] = capPreviewContextCount(stored.childFolderPreview?.inputMethods?.[key]); + }); + const childFolderLast = stored.childFolderPreview?.lastEvent; + if (childFolderLast && typeof childFolderLast === 'object' && !Array.isArray(childFolderLast)) { + state.childFolderPreview.lastEvent = { + at: String(childFolderLast.at || ''), + type: ['render', 'binding', 'menu-open'].includes(childFolderLast.type) ? childFolderLast.type : 'menu-open', + outcome: ['success', 'failure'].includes(childFolderLast.outcome) ? childFolderLast.outcome : 'failure', + inputMethod: CHILD_FOLDER_PREVIEW_INPUT_METHODS.includes(childFolderLast.inputMethod) + ? childFolderLast.inputMethod + : 'unknown', + reason: childFolderLast.reason === 'document-unavailable' ? 'document-unavailable' : '' + }; + } const last = stored.lastEvent; if (last && typeof last === 'object' && !Array.isArray(last)) { state.lastEvent = { @@ -137,6 +164,17 @@ reason: PREVIEW_CONTEXT_FAILURE_REASONS.includes(details.reason) ? details.reason : '' }; }; + const setChildFolderPreviewLastEvent = (type, details = {}) => { + state.childFolderPreview.lastEvent = { + at: nowIso(), + type, + outcome: details.success === false ? 'failure' : 'success', + inputMethod: CHILD_FOLDER_PREVIEW_INPUT_METHODS.includes(details.inputMethod) + ? details.inputMethod + : 'unknown', + reason: details.reason === 'document-unavailable' ? 'document-unavailable' : '' + }; + }; return Object.freeze({ recordBinding(details = {}) { bump(state.counters, 'bindAttempts'); metric(details, 'bindings'); @@ -175,6 +213,27 @@ persist(true); }, recordQuickActionBypass() { bump(state.counters, 'quickActionBypasses'); persist(); }, + recordChildFolderPreviewRender() { + bump(state.childFolderPreview.counters, 'chipsRendered'); + setChildFolderPreviewLastEvent('render'); + persist(); + }, + recordChildFolderPreviewBinding() { + bump(state.childFolderPreview.counters, 'bindings'); + setChildFolderPreviewLastEvent('binding'); + persist(); + }, + recordChildFolderPreviewMenuOpen(details = {}) { + bump(state.childFolderPreview.counters, 'menuOpenAttempts'); + const success = details.success === true; + bump(state.childFolderPreview.counters, success ? 'menuOpens' : 'menuOpenFailures'); + bump( + state.childFolderPreview.inputMethods, + CHILD_FOLDER_PREVIEW_INPUT_METHODS.includes(details.inputMethod) ? details.inputMethod : 'unknown' + ); + setChildFolderPreviewLastEvent('menu-open', { ...details, success }); + persist(true); + }, snapshot: () => JSON.parse(JSON.stringify(state)) }); }; @@ -393,6 +452,49 @@ return true; }; + const findDockerNestedPreviewSourceRow = (entry = {}) => { + const containerId = String(entry?.shortId || entry?.id || entry?.info?.Id || '') + .replace(/^sha256:/i, '') + .trim(); + if (containerId) { + const exactTrigger = doc?.getElementById?.(containerId) || null; + const exactRow = exactTrigger ? jq(exactTrigger).closest('tr') : jq(); + if (exactRow.length) return exactRow.first(); + } + const containerName = String(entry?.name || entry?.info?.Name || '').trim(); + if (!containerName) return jq(); + return jq('tr.folder-element, div.folder-storage > tr').filter((_, row) => ( + String(jq(row).find('td.ct-name .appname').first().text() || '').trim() === containerName + )).first(); + }; + + const bindDockerNestedPreviewContext = (options = {}) => { + const $item = options.$item; + if (!jq || !$item || !$item.length) return false; + const settings = options.settings || {}; + const contextMode = Number(settings.context ?? 1); + if (contextMode === 0) return false; + if (contextMode === 1) { + return bindDockerPreviewDefaultContextBridge( + $item, + findDockerNestedPreviewSourceRow(options.entry || {}), + settings + ); + } + if (contextMode !== 2 || typeof options.bindAdvancedContext !== 'function') return false; + const $trigger = options.$tooltipTrigger && options.$tooltipTrigger.length + ? options.$tooltipTrigger + : $item.find('.fv-preview-trigger').first(); + const entry = options.entry || {}; + const containerId = String(entry?.shortId || entry?.id || entry?.info?.Id || '') + .replace(/^sha256:/i, '') + .trim(); + const containerName = String(entry?.name || entry?.info?.Name || '').trim(); + if (!$trigger.length || !containerId || !containerName) return false; + $trigger.attr('id', `folder-preview-${containerId}`).removeAttr('onclick'); + return options.bindAdvancedContext($trigger, containerName) === true; + }; + const auditDockerPreviewContextBridges = ($preview, settings = {}) => { if (!jq || !$preview || !$preview.length) return null; const rowMode = resolveDockerPreviewContextRowMode($preview, settings); @@ -522,6 +624,9 @@ } try { const $clone = nativePreview.$clone.addClass(options.autostart ? 'autostart' : ''); + $clone + .attr('data-fv-container-id', String(options.ctid || '').trim()) + .attr('data-fv-container-name', String(options.containerName || '').trim()); options.$preview.append($clone); const $loadIcon = $clone.find('i[id^="load-"]').first(); const loadId = String($loadIcon.attr('id') || '').trim(); @@ -1190,8 +1295,12 @@ return Object.freeze({ sanitizeDockerPreviewContextClone, bindDockerPreviewDefaultContextBridge, + bindDockerNestedPreviewContext, auditDockerPreviewContextBridges, getPreviewContextDiagnosticsSnapshot: previewContextDiagnostics.snapshot, + recordChildFolderPreviewRender: previewContextDiagnostics.recordChildFolderPreviewRender, + recordChildFolderPreviewBinding: previewContextDiagnostics.recordChildFolderPreviewBinding, + recordChildFolderPreviewMenuOpen: previewContextDiagnostics.recordChildFolderPreviewMenuOpen, normalizeDockerPreviewStatusMarkup, cloneDockerSingleRowPreviewSource, renderDockerSingleRowPreview, diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.reconcile.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.reconcile.js index b9e2a3a8..7cd6d815 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.reconcile.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.reconcile.js @@ -20,6 +20,19 @@ const DOCKER_LIFECYCLE_REFRESH_CALLBACK_NAME = '__fvplusDockerLifecycleRefresh'; const DOCKER_LIFECYCLE_REFRESH_DELAYS_MS = Object.freeze([0, 750, 2000]); const DOCKER_LIFECYCLE_ACTIONS = new Set(['start', 'stop', 'pause', 'resume', 'restart']); + const DOCKER_LIFECYCLE_PREVIEW_SURFACE_SELECTOR = [ + '.folder-preview [data-fv-container-id]', + '.folder-preview [data-fv-container-name]' + ].join(', '); + const DOCKER_LIFECYCLE_STATUS_ICON_SELECTOR = [ + '.fv-preview-status-compact i.fa', + '.fv-preview-status-inline i.fa', + 'i.started', + 'i.paused', + 'i.stopped' + ].join(', '); + const DOCKER_LIFECYCLE_ICON_SNAPSHOT_ATTRIBUTE = 'data-fv-lifecycle-icon-class'; + const DOCKER_LIFECYCLE_SURFACE_PENDING_ATTRIBUTE = 'data-fv-lifecycle-pending'; const createApi = (deps = {}) => { const win = deps.window || fallbackWindow; @@ -86,22 +99,99 @@ if (action === 'pause') return state.active === true && state.paused === true; return ['start', 'resume', 'restart'].includes(action) && state.active === true && state.paused !== true; }; + const getDockerLifecyclePreviewSurfaces = (request = {}) => { + const entry = resolveDockerLifecycleEntry(request); + const requestId = String(request?.container || '').replace(/^sha256:/i, '').trim(); + const entryIds = [ + requestId, + entry?.shortId, + entry?.id, + entry?.info?.Id + ].map((value) => String(value || '').replace(/^sha256:/i, '').trim()).filter(Boolean); + const entryName = String(entry?.name || entry?.info?.Name || '').trim(); + const matchesIdentity = (surface) => { + const surfaceId = String(surface?.getAttribute?.('data-fv-container-id') || '').replace(/^sha256:/i, '').trim(); + const surfaceName = String(surface?.getAttribute?.('data-fv-container-name') || '').trim(); + const idMatch = surfaceId && entryIds.some((candidate) => ( + candidate === surfaceId + || (candidate.length >= 12 && surfaceId.length >= 12 && candidate.slice(0, 12) === surfaceId.slice(0, 12)) + )); + return Boolean(idMatch || (entryName && surfaceName === entryName)); + }; + return Array.from(doc?.querySelectorAll?.(DOCKER_LIFECYCLE_PREVIEW_SURFACE_SELECTOR) || []) + .filter(matchesIdentity); + }; + const getDockerLifecycleStatusIcons = (surface) => Array.from( + surface?.querySelectorAll?.(DOCKER_LIFECYCLE_STATUS_ICON_SELECTOR) || [] + ); + const markDockerLifecycleSurfacePending = (request = {}) => { + const action = String(request?.action || '').trim().toLowerCase(); + const surfaces = getDockerLifecyclePreviewSurfaces(request); + let iconCount = 0; + surfaces.forEach((surface) => { + surface?.setAttribute?.(DOCKER_LIFECYCLE_SURFACE_PENDING_ATTRIBUTE, 'true'); + surface?.setAttribute?.('aria-busy', 'true'); + surface?.setAttribute?.('data-fv-lifecycle-action', action); + getDockerLifecycleStatusIcons(surface).forEach((icon) => { + if (!icon?.hasAttribute?.(DOCKER_LIFECYCLE_ICON_SNAPSHOT_ATTRIBUTE)) { + icon?.setAttribute?.(DOCKER_LIFECYCLE_ICON_SNAPSHOT_ATTRIBUTE, String(icon?.getAttribute?.('class') || '')); + } + const classNames = String(icon?.getAttribute?.('class') || '') + .split(/\s+/) + .filter((className) => className && ![ + 'fa-play', 'fa-pause', 'fa-square', 'fa-refresh', 'fa-spin', + 'started', 'paused', 'stopped', 'green-text', 'orange-text', 'red-text', + 'fv-preview-status-started', 'fv-preview-status-paused', 'fv-preview-status-stopped', + 'fv-preview-lifecycle-pending' + ].includes(className)); + icon?.setAttribute?.('class', [...new Set([...classNames, 'fa', 'fa-refresh', 'fa-spin', 'fv-preview-lifecycle-pending'])].join(' ')); + icon?.setAttribute?.('aria-busy', 'true'); + iconCount += 1; + }); + }); + return { surfaces, iconCount }; + }; + const clearDockerLifecycleSurfacePending = (request = {}) => { + const matched = getDockerLifecyclePreviewSurfaces(request); + const pending = Array.from(doc?.querySelectorAll?.(`[${DOCKER_LIFECYCLE_SURFACE_PENDING_ATTRIBUTE}="true"]`) || []); + const surfaces = Array.from(new Set([...matched, ...pending])); + let restoredIconCount = 0; + surfaces.forEach((surface) => { + getDockerLifecycleStatusIcons(surface).forEach((icon) => { + if (icon?.hasAttribute?.(DOCKER_LIFECYCLE_ICON_SNAPSHOT_ATTRIBUTE)) { + icon?.setAttribute?.('class', String(icon?.getAttribute?.(DOCKER_LIFECYCLE_ICON_SNAPSHOT_ATTRIBUTE) || '')); + icon?.removeAttribute?.(DOCKER_LIFECYCLE_ICON_SNAPSHOT_ATTRIBUTE); + restoredIconCount += 1; + } + icon?.removeAttribute?.('aria-busy'); + }); + surface?.removeAttribute?.(DOCKER_LIFECYCLE_SURFACE_PENDING_ATTRIBUTE); + surface?.removeAttribute?.('data-fv-lifecycle-action'); + surface?.removeAttribute?.('aria-busy'); + }); + return { surfaces, restoredIconCount }; + }; const countBusyPreviewActionIcons = () => doc?.querySelectorAll?.([ '.folder-preview .fv-preview-action-slot i.fa-spin', '.folder-preview .fv-preview-action-slot i.fa-spinner', - '.folder-preview .fv-preview-action-slot i.fa-circle-o-notch' + '.folder-preview .fv-preview-action-slot i.fa-circle-o-notch', + `.folder-preview i[${DOCKER_LIFECYCLE_ICON_SNAPSHOT_ATTRIBUTE}]` ].join(', ')).length || 0; const defaultPrepareLifecycleSurface = (request = {}) => { + const pendingSurface = markDockerLifecycleSurfacePending(request); appendDockerBulkUpdateTrace('lifecycleSurfacePrepared', { action: String(request?.action || '').trim().toLowerCase(), observedState: defaultLifecycleStateSnapshot(request), + previewSurfaceCount: pendingSurface.surfaces.length, + pendingStatusIconCount: pendingSurface.iconCount, busyPreviewActionIconCount: countBusyPreviewActionIcons() }); - return true; + return pendingSurface.surfaces.length > 0; }; const defaultFinalizeLifecycleSurface = (request = {}, outcome = {}) => { const entry = resolveDockerLifecycleEntry(request); const name = String(entry?.info?.Name || '').trim(); + const restoredSurface = clearDockerLifecycleSurfacePending(request); syncDockerVisibleFoldersFromRuntimeCache(name ? new Set([name]) : null); const remainingBusyIconCount = countBusyPreviewActionIcons(); appendDockerBulkUpdateTrace('lifecycleSurfaceFinalized', { @@ -112,6 +202,8 @@ attempt: Number(outcome?.attempt || 0), observedState: outcome?.observedState || defaultLifecycleStateSnapshot(request), matchedRuntimeEntry: Boolean(entry), + previewSurfaceCount: restoredSurface.surfaces.length, + restoredStatusIconCount: restoredSurface.restoredIconCount, remainingBusyPreviewActionIconCount: remainingBusyIconCount }); queueDockerSupportBundlePageSnapshot('lifecycle-surface-finalized', 80); @@ -527,7 +619,11 @@ })) .then((success) => { const observedState = getDockerLifecycleStateSnapshot(request); + const restartWarmupPending = action === 'restart' + && attemptIndex === 0 + && dockerLifecycleRefreshDelaysMs.length > 1; const settled = success === true + && restartWarmupPending !== true && isDockerLifecycleStateSettled && isDockerLifecycleStateSettled(request) === true; if (settled) { diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js index e168583d..871c5ec3 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js @@ -29,9 +29,9 @@ const runtimeJquery = window.jQuery || window.$ || null; const DEFAULT_FOLDER_STATUS_COLORS = folderContract?.DEFAULT_FOLDER_STATUS_COLORS || Object.freeze({ - started: '#ffffff', + started: '#55b72d', paused: '#b8860b', - stopped: '#ff4d4d' + stopped: '#ff4d4d', text: '#ffffff' }); const DEFAULT_FOLDER_ACCENT_COLOR = folderContract?.DEFAULT_FOLDER_ACCENT_COLOR || '#ffca63'; const DEFAULT_PREVIEW_BORDER_COLOR = folderContract?.DEFAULT_PREVIEW_BORDER_COLOR || '#afa89e'; @@ -44,7 +44,7 @@ const FOLDER_STATUS_COLOR_STYLE_PROPS = Object.freeze({ started: '--fvplus-folder-status-started', paused: '--fvplus-folder-status-paused', - stopped: '--fvplus-folder-status-stopped' + stopped: '--fvplus-folder-status-stopped', text: '--fvplus-folder-status-text' }); const normalizeStatusHexColor = typeof folderContract?.normalizeHexColor === 'function' @@ -159,20 +159,19 @@ }; const getFolderStatusColors = (settings) => { - const source = settings && typeof settings === 'object' ? settings : {}; + const source = settings && typeof settings === 'object' ? settings : {}; const normalizedStarted = normalizeStatusHexColor(source.status_color_started, DEFAULT_FOLDER_STATUS_COLORS.started); const startedExplicit = source.status_color_started_explicit === true || source.statusColorStartedExplicit === true; return { - started: normalizeStatusHexColor(source.status_color_started, DEFAULT_FOLDER_STATUS_COLORS.started), + started: !startedExplicit && normalizedStarted === (folderContract?.LEGACY_DEFAULT_FOLDER_STARTED_COLOR || '#ffffff') ? DEFAULT_FOLDER_STATUS_COLORS.started : normalizedStarted, paused: normalizeStatusHexColor(source.status_color_paused, DEFAULT_FOLDER_STATUS_COLORS.paused), - stopped: normalizeStatusHexColor(source.status_color_stopped, DEFAULT_FOLDER_STATUS_COLORS.stopped) + stopped: normalizeStatusHexColor(source.status_color_stopped, DEFAULT_FOLDER_STATUS_COLORS.stopped), text: normalizeStatusHexColor(source.status_color_text, DEFAULT_FOLDER_STATUS_COLORS.text) }; }; const getFolderStatusColorOverrides = (settings) => { - const colors = getFolderStatusColors(settings); - return { - started: colors.started !== DEFAULT_FOLDER_STATUS_COLORS.started ? colors.started : '', + const colors = getFolderStatusColors(settings); return { + started: colors.started, paused: colors.paused !== DEFAULT_FOLDER_STATUS_COLORS.paused ? colors.paused : '', - stopped: colors.stopped !== DEFAULT_FOLDER_STATUS_COLORS.stopped ? colors.stopped : '' + stopped: colors.stopped !== DEFAULT_FOLDER_STATUS_COLORS.stopped ? colors.stopped : '', text: colors.text !== DEFAULT_FOLDER_STATUS_COLORS.text ? colors.text : '' }; }; @@ -193,7 +192,7 @@ const locked = settings?.status_color_lock === true || settings?.statusColorLock === true; style.removeProperty(FOLDER_STATUS_COLOR_STYLE_PROPS.started); style.removeProperty(FOLDER_STATUS_COLOR_STYLE_PROPS.paused); - style.removeProperty(FOLDER_STATUS_COLOR_STYLE_PROPS.stopped); + style.removeProperty(FOLDER_STATUS_COLOR_STYLE_PROPS.stopped); style.removeProperty(FOLDER_STATUS_COLOR_STYLE_PROPS.text); if (overrides.started || locked) { style.setProperty(FOLDER_STATUS_COLOR_STYLE_PROPS.started, overrides.started || getFolderStatusColors(settings).started, locked ? 'important' : ''); } @@ -203,6 +202,7 @@ if (overrides.stopped || locked) { style.setProperty(FOLDER_STATUS_COLOR_STYLE_PROPS.stopped, overrides.stopped || getFolderStatusColors(settings).stopped, locked ? 'important' : ''); } + if (overrides.text || locked) style.setProperty(FOLDER_STATUS_COLOR_STYLE_PROPS.text, overrides.text || getFolderStatusColors(settings).text, locked ? 'important' : ''); }; const applyFolderAccentStyle = ($folderRow, settings) => { diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js index 88235674..c3883b8c 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.chrome.js @@ -17,6 +17,7 @@ preview: { title: 'Preview', icon: 'fa-eye', advanced: false, description: 'Control preview layout, context, borders, dividers, and inline preview actions.' }, chevron: { title: 'Chevron', icon: 'fa-chevron-down', advanced: false, description: 'Pick the dropdown style and the primary / hover colors used in folder rows.' }, status: { title: 'Status', icon: 'fa-heartbeat', advanced: false, description: 'Adjust status colors and optional health or warning thresholds for the folder.' }, + webuiProfiles: { title: 'WebUI Profiles', icon: 'fa-external-link', advanced: false, description: 'Create named groups that open only selected Docker container WebUIs.' }, rules: { title: 'Rules', icon: 'fa-code', advanced: true, description: 'Optional automation rules for auto-including matching containers or VMs.' }, actions: { title: 'Actions', icon: 'fa-bolt', advanced: true, description: 'Optional custom actions that appear in this folder’s context menu.' }, advanced: { title: 'Advanced', icon: 'fa-sliders', advanced: true, description: 'Tune Docker / VM / Dashboard specific behavior and other advanced defaults.' } @@ -46,11 +47,12 @@ { key: 'color', title: 'Chevron Color', description: 'Override the folder row chevron color.', fields: ['dropdown_color'] } ], status: [ - { key: 'status-colors', title: 'Status Colors', description: 'Set the started, paused, and stopped colors used by folder rows.', fields: ['status_color_started', 'status_color_paused', 'status_color_stopped'], match: (row) => Boolean(row?.querySelector?.('.folder-status-colors-dd')) }, + { key: 'status-colors', title: 'Status Colors', description: 'Set indicator colors and the independent status text color used by folder rows.', fields: ['status_color_started', 'status_color_paused', 'status_color_stopped', 'status_color_text'], match: (row) => Boolean(row?.querySelector?.('.folder-status-colors-dd')) }, { key: 'accent', title: 'Accent Bar', description: 'Enable and color the optional folder accent bar.', fields: ['folder_accent_enabled', 'folder_accent_color'] }, { key: 'thresholds', title: 'Status Thresholds', description: 'Override warning levels for this folder only.', advancedOnly: true, fields: ['status_warn_stopped_percent'] }, { key: 'health', title: 'Docker Health', description: 'Tune Docker-specific folder health scoring.', advancedOnly: true, fields: ['health_warn_stopped_percent', 'health_critical_stopped_percent', 'health_profile', 'health_updates_mode', 'health_all_stopped_mode'] } ], + webuiProfiles: [{ key: 'webui-profiles', title: 'Custom WebUI Profiles', description: 'Select the direct folder members opened by each named profile.', match: (row) => row?.id === 'fvWebuiProfilesSection' }], rules: [ { key: 'auto-rules', title: 'Auto-Rules', description: 'Create plugin-wide include or exclude rules that target this folder.', keepEmpty: true, match: (row) => row?.id === 'fvFolderAutoRulesPanel' }, { key: 'regex', title: 'Legacy Rule Compatibility', description: 'Review and convert an older folder-level name regex.', fields: ['regex'] } @@ -75,7 +77,6 @@ let currentSection = 'general'; let bootstrapWatchdogArmed = false; let folderEditorTypeApi = null; - const resolveFolderEditorTypeModule = () => { if (pageType === 'docker') { return root.FolderViewPlusFolderEditorTypeDocker || null; @@ -85,7 +86,6 @@ } return null; }; - const getFolderEditorTypeApi = () => { if (folderEditorTypeApi) { return folderEditorTypeApi; @@ -101,7 +101,6 @@ } return folderEditorTypeApi; }; - const mergeSectionRows = (baseRows, extraRows) => { const merged = { ...baseRows }; const source = extraRows && typeof extraRows === 'object' ? extraRows : {}; @@ -393,7 +392,7 @@
    Started Paused - Stopped + StoppedText Accent
    @@ -538,6 +537,7 @@ findBasicByFieldName(form, 'status_color_started'), findBasicByFieldName(form, 'status_warn_stopped_percent') ], + webuiProfiles: pageType === 'docker' ? [form.querySelector('#fvWebuiProfilesSection')] : [], rules: [ findBasicByFieldName(form, 'regex'), root.document.getElementById('fvFolderAutoRulesPanel') diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.preview.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.preview.js index edff31d1..84201633 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.preview.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.preview.js @@ -113,6 +113,7 @@ const safeHoverAnimation = ['lift', 'bounce', 'pop', 'glow', 'flip', 'wiggle'].includes(hoverAnimationToken) ? hoverAnimationToken : 'none'; const accentEnabled = isFolderAccentEnabled({ folder_accent_enabled: form.folder_accent_enabled?.checked === true }); const accentColor = normalizeHexColor(form.folder_accent_color?.value, deps.defaultFolderAccentColor || '#ffca63'); + const statusTextColor = normalizeHexColor(form.status_color_text?.value, deps.defaultFolderStatusColors?.text || '#ffffff'); const icon = String(form.icon?.value || '').trim() || deps.defaultFolderIconPath || ''; const name = String(form.name?.value || '').trim() || 'Unnamed folder'; const hideNestedPreviewItems = form.preview_hide_nested_items?.checked === true; @@ -233,9 +234,12 @@ } const statusNode = memberNode.querySelector('.fv-live-member-status'); if (statusNode instanceof HTMLElement) { - statusNode.style.color = safeStateColor; + statusNode.style.color = statusNode.classList.contains('is-symbol') ? safeStateColor : statusTextColor; } }); + canvas.find('.fv-live-member-status:not(.is-symbol)').each((_, statusNode) => { + if (statusNode instanceof HTMLElement) statusNode.style.color = statusTextColor; + }); }; const updateLiveSummary = () => { @@ -280,9 +284,10 @@ ? 'Preview disabled' : `${previewLabel} - ${normalizePreviewRowLimit(form.preview_rows?.value) === 0 ? 'Unlimited rows' : `${normalizePreviewRowLimit(form.preview_rows?.value)} row${normalizePreviewRowLimit(form.preview_rows?.value) === 1 ? '' : 's'}`}` ); - $('#fvSwatchStarted').css('background-color', normalizeHexColor(form.status_color_started?.value, deps.defaultFolderStatusColors?.started || '#ffffff')); + $('#fvSwatchStarted').css('background-color', normalizeHexColor(form.status_color_started?.value, deps.defaultFolderStatusColors?.started || '#55b72d')); $('#fvSwatchPaused').css('background-color', normalizeHexColor(form.status_color_paused?.value, deps.defaultFolderStatusColors?.paused || '#b8860b')); $('#fvSwatchStopped').css('background-color', normalizeHexColor(form.status_color_stopped?.value, deps.defaultFolderStatusColors?.stopped || '#ff4d4d')); + $('#fvSwatchText').css('background-color', normalizeHexColor(form.status_color_text?.value, deps.defaultFolderStatusColors?.text || '#ffffff')); $('#fvSwatchAccent').css('background-color', accentColor); $('#fvAccentSwatchItem').toggle(accentEnabled); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.schema.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.schema.js index 319fb44e..f09b5f49 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.schema.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.schema.js @@ -28,6 +28,7 @@ preview: Object.freeze({ title: 'Preview', description: 'How the folder row, preview layout, borders, and context surface render.', icon: 'fa-eye', advanced: false, supportsDefaults: true, supportsRevert: true }), chevron: Object.freeze({ title: 'Chevron', description: 'Chevron style, normal color, hover color, and interaction styling.', icon: 'fa-chevron-down', advanced: false, supportsDefaults: true, supportsRevert: true }), status: Object.freeze({ title: 'Status', description: 'Status palette, accent styling, and optional folder health or status thresholds.', icon: 'fa-heartbeat', advanced: false, supportsDefaults: true, supportsRevert: true }), + webuiProfiles: Object.freeze({ title: 'WebUI Profiles', description: 'Create named groups that open only selected Docker container WebUIs.', icon: 'fa-external-link', advanced: false, supportsDefaults: false, supportsRevert: false }), rules: Object.freeze({ title: 'Rules', description: 'Managed auto-assignment rules, with conversion support for older folder regex data.', icon: 'fa-code', advanced: true, supportsDefaults: true, supportsRevert: true }), actions: Object.freeze({ title: 'Actions', description: 'Quick actions and custom folder actions available from the folder menu.', icon: 'fa-bolt', advanced: true, supportsDefaults: false, supportsRevert: false }), advanced: Object.freeze({ title: 'Advanced', description: 'Override behavior, expansion defaults, dashboard behavior, and niche controls.', icon: 'fa-sliders', advanced: true, supportsDefaults: true, supportsRevert: true }) @@ -72,6 +73,7 @@ 'status_color_started', 'status_color_paused', 'status_color_stopped', + 'status_color_text', 'status_color_lock', 'health_warn_stopped_percent', 'health_critical_stopped_percent', @@ -80,6 +82,7 @@ 'health_all_stopped_mode', 'status_warn_stopped_percent' ]), + webuiProfiles: Object.freeze(['webui_profiles']), rules: Object.freeze(['regex']), advanced: Object.freeze(['update_column', 'override_default_actions', 'default_action', 'expand_tab', 'expand_dashboard', 'dashboard_overflow']) }); @@ -123,9 +126,10 @@ status: Object.freeze({ folder_accent_enabled: false, folder_accent_color: deps.defaultFolderAccentColor || '#ffca63', - status_color_started: deps.defaultFolderStatusColors?.started || '#ffffff', + status_color_started: deps.defaultFolderStatusColors?.started || '#55b72d', status_color_paused: deps.defaultFolderStatusColors?.paused || '#b8860b', status_color_stopped: deps.defaultFolderStatusColors?.stopped || '#ff4d4d', + status_color_text: deps.defaultFolderStatusColors?.text || '#ffffff', status_color_lock: false, health_warn_stopped_percent: '', health_critical_stopped_percent: '', @@ -170,6 +174,7 @@ icon: 'Folder icon', folder_webui: 'Folder WebUI', folder_webui_url: 'Folder WebUI URL', + webui_profiles: 'WebUI profiles', preview: 'Preview mode', preview_hover: 'Hover-only preview', preview_update: 'Update highlighting', @@ -206,6 +211,7 @@ status_color_started: 'Started status color', status_color_paused: 'Paused status color', status_color_stopped: 'Stopped status color', + status_color_text: 'Status text color', status_color_lock: 'Lock status colors', health_warn_stopped_percent: 'Health warn threshold', health_critical_stopped_percent: 'Health critical threshold', diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.shared.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.shared.js index 5ce8d742..5ddba776 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.shared.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.shared.js @@ -227,15 +227,28 @@ folder_accent_color: typeof deps.normalizeHexColor === 'function' ? deps.normalizeHexColor(settings.folder_accent_color, deps.defaultFolderAccentColor || '#ffca63') : String(settings.folder_accent_color || deps.defaultFolderAccentColor || '#ffca63'), - status_color_started: typeof deps.normalizeHexColor === 'function' - ? deps.normalizeHexColor(settings.status_color_started, deps.defaultFolderStatusColors?.started || '#ffffff') - : String(settings.status_color_started || deps.defaultFolderStatusColors?.started || '#ffffff'), + status_color_started: (() => { + const defaultStarted = deps.defaultFolderStatusColors?.started || '#55b72d'; + const normalizedStarted = typeof deps.normalizeHexColor === 'function' + ? deps.normalizeHexColor(settings.status_color_started, defaultStarted) + : String(settings.status_color_started || defaultStarted).toLowerCase(); + const startedExplicit = settings.status_color_started_explicit === true + || settings.statusColorStartedExplicit === true; + return !startedExplicit && normalizedStarted === '#ffffff' + ? defaultStarted + : normalizedStarted; + })(), + status_color_started_explicit: settings.status_color_started_explicit === true + || settings.statusColorStartedExplicit === true, status_color_paused: typeof deps.normalizeHexColor === 'function' ? deps.normalizeHexColor(settings.status_color_paused, deps.defaultFolderStatusColors?.paused || '#b8860b') : String(settings.status_color_paused || deps.defaultFolderStatusColors?.paused || '#b8860b'), status_color_stopped: typeof deps.normalizeHexColor === 'function' ? deps.normalizeHexColor(settings.status_color_stopped, deps.defaultFolderStatusColors?.stopped || '#ff4d4d') : String(settings.status_color_stopped || deps.defaultFolderStatusColors?.stopped || '#ff4d4d'), + status_color_text: typeof deps.normalizeHexColor === 'function' + ? deps.normalizeHexColor(settings.status_color_text, deps.defaultFolderStatusColors?.text || '#ffffff') + : String(settings.status_color_text || deps.defaultFolderStatusColors?.text || '#ffffff'), status_color_lock: settings.status_color_lock === true || settings.statusColorLock === true, health_warn_stopped_percent: parseOptionalThresholdInput(settings.health_warn_stopped_percent), health_critical_stopped_percent: parseOptionalThresholdInput(settings.health_critical_stopped_percent), diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.type-docker.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.type-docker.js index 65aa08f7..a39a9bc8 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.type-docker.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.type-docker.js @@ -124,6 +124,8 @@ State: state, RawState: state, UpdateAvailable: manager === 'dockerman' && updated === false, + WebUi: String(entry?.WebUi || entry?.WebUI || entry?.webui || '').trim(), + webuiCapability: entry?.webuiCapability === true ? true : (entry?.webuiCapability === false ? false : null), Identity: { kind: 'docker', containerId: String(entry?.shortId || entry?.id || entry?.Id || '').replace(/^sha256:/i, '').slice(0, 64), diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.js index 6f71202a..9bfc28b5 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.js @@ -278,10 +278,11 @@ const folderParentPickerModule = window.FolderViewPlusFolderEditorParentPicker | const folderIconApiModule = window.FolderViewPlusFolderIconApi || null; const folderEditorRegexSelectionModule = window.FolderViewPlusFoundationModules?.folderEditorRegexSelection || null; const folderEditorMemberListModule = window.FolderViewPlusFoundationModules?.folderEditorMemberList || null; +const folderWebuiProfilesModule = window.FolderViewPlusFoundationModules?.folderWebuiProfiles || null; const DEFAULT_FOLDER_STATUS_COLORS = folderContract?.DEFAULT_FOLDER_STATUS_COLORS || { - started: '#ffffff', + started: '#55b72d', paused: '#b8860b', - stopped: '#ff4d4d' + stopped: '#ff4d4d', text: '#ffffff' }; const DEFAULT_FOLDER_ACCENT_COLOR = folderContract?.DEFAULT_FOLDER_ACCENT_COLOR || '#ffca63'; const DEFAULT_BORDER_COLOR = folderContract?.DEFAULT_PREVIEW_BORDER_COLOR || '#afa89e'; @@ -402,6 +403,7 @@ if (!folderEditorRegexSelectionModule || typeof folderEditorRegexSelectionModule if (!folderEditorMemberListModule || typeof folderEditorMemberListModule.createApi !== 'function' || typeof folderEditorMemberListModule.normalizeChildFolderOrder !== 'function') { folderEditorBootstrapMissingModules.push('folder.editor.member-list.js'); } +if (!folderWebuiProfilesModule || typeof folderWebuiProfilesModule.createEditorApi !== 'function') folderEditorBootstrapMissingModules.push('folder.webui-profiles.js'); if (!bulkAssignmentSharedModule || typeof bulkAssignmentSharedModule.createApi !== 'function') { folderEditorBootstrapMissingModules.push('folderviewplus.bulk-assignment.shared.js'); } @@ -441,7 +443,7 @@ let folderEditorPreviewRuntimeApi = null; let folderEditorStateApi = null; let folderEditorMembersApi = null; let folderEditorRegexSelectionApi = null; -let folderEditorMemberListApi = null; +let folderEditorMemberListApi = null, folderWebuiProfilesApi = null; let folderEditorIconsApi = null; let folderEditorTypeApi = null; let folderBulkAssignmentSharedApi = null; @@ -452,7 +454,7 @@ let editorRecalcTimer = null; let nameRegexSyncTimer = null; let lastNameRegexSyncValue = ''; let editorMode = 'basic'; -let activeEditorSection = 'general'; +let activeEditorSection = folderEditorHashParams.get('section') || folderEditorQueryParams.get('section') || 'general'; let advancedSectionCollapsedState = {}; let memberBulkMoveInFlight = false; let memberBulkMoveUndoState = null; @@ -480,7 +482,7 @@ const SMART_DEFAULT_FIELD_NAMES = new Set([ 'folder_accent_color', 'status_color_started', 'status_color_paused', - 'status_color_stopped', + 'status_color_stopped', 'status_color_text', 'status_color_lock' ]); @@ -1072,9 +1074,9 @@ const buildParentSmartDefaults = (parentFolder) => { dropdown_hover_color: normalizeHexColor(settings.dropdown_hover_color, DEFAULT_DROPDOWN_HOVER_COLOR), folder_accent_enabled: isFolderAccentEnabled(settings), folder_accent_color: normalizeHexColor(settings.folder_accent_color, DEFAULT_FOLDER_ACCENT_COLOR), - status_color_started: normalizeHexColor(settings.status_color_started, DEFAULT_FOLDER_STATUS_COLORS.started), + status_color_started: utils?.getFolderStatusColors?.(settings)?.started || normalizeHexColor(settings.status_color_started, DEFAULT_FOLDER_STATUS_COLORS.started), status_color_paused: normalizeHexColor(settings.status_color_paused, DEFAULT_FOLDER_STATUS_COLORS.paused), - status_color_stopped: normalizeHexColor(settings.status_color_stopped, DEFAULT_FOLDER_STATUS_COLORS.stopped), + status_color_stopped: normalizeHexColor(settings.status_color_stopped, DEFAULT_FOLDER_STATUS_COLORS.stopped), status_color_text: normalizeHexColor(settings.status_color_text, DEFAULT_FOLDER_STATUS_COLORS.text), status_color_lock: settings.status_color_lock === true || settings.statusColorLock === true }; }; @@ -1316,23 +1318,20 @@ const getFolderEditorIconsApi = () => { return folderEditorIconsApi; }; -const renderBuiltInIconPicker = () => { - getFolderEditorIconsApi()?.renderBuiltInIconPicker(); -}; - -const bindIconPickerEvents = async () => { - await getFolderEditorIconsApi()?.bindIconPickerEvents(); -}; +const renderBuiltInIconPicker = () => { getFolderEditorIconsApi()?.renderBuiltInIconPicker(); }; +const bindIconPickerEvents = async () => { await getFolderEditorIconsApi()?.bindIconPickerEvents(); }; const getAllMembers = () => { const map = new Map(); [...selectedRegex, ...selected, ...choose].forEach((member) => { - if (!map.has(member.Name)) { - map.set(member.Name, member); - } + if (!map.has(member.Name)) map.set(member.Name, member); }); return [...map.values()]; }; - +const getFolderWebuiProfilesApi = () => { + if (folderWebuiProfilesApi || typeof folderWebuiProfilesModule?.createEditorApi !== 'function') return folderWebuiProfilesApi; + folderWebuiProfilesApi = folderWebuiProfilesModule.createEditorApi({ window, document, form: getForm(), type, getMembers: getAllMembers, translate: folderEditorT }); + return folderWebuiProfilesApi; +}; const computeFormSnapshot = () => { const form = getForm(); const state = { @@ -1341,12 +1340,10 @@ const computeFormSnapshot = () => { childFolders: [], actions: $('input[name*="custom_action"]').map((_, el) => $(el).val()).get() }; - $(form).find(':input[name]').each((_, element) => { if (!element.name) { return; } - const value = element.type === 'checkbox' ? element.checked : $(element).val(); if (Object.prototype.hasOwnProperty.call(state.fields, element.name)) { if (!Array.isArray(state.fields[element.name])) { @@ -1357,7 +1354,6 @@ const computeFormSnapshot = () => { state.fields[element.name] = value; } }); - $('table.sortable > tbody > tr').each((_, row) => { const input = $(row).find('input.container-switch'); state.members.push({ @@ -1374,7 +1370,6 @@ const computeFormSnapshot = () => { return JSON.stringify(state); }; - const getFolderEditorStateApi = () => { if (folderEditorStateApi || typeof folderEditorStateModule?.createApi !== 'function') { return folderEditorStateApi; @@ -1744,6 +1739,7 @@ const registerFolderEditorModuleTeardown = () => { folderThemeSurfaceBinding?.disconnect(); folderEditorRegexSelectionApi?.dispose(); folderEditorMemberListApi?.dispose(); + folderWebuiProfilesApi?.dispose(); }, { once: true }); }; @@ -1751,7 +1747,7 @@ const resetStatusColorDefaults = () => { const form = $('div.canvas > form')[0]; form.status_color_started.value = DEFAULT_FOLDER_STATUS_COLORS.started; form.status_color_paused.value = DEFAULT_FOLDER_STATUS_COLORS.paused; - form.status_color_stopped.value = DEFAULT_FOLDER_STATUS_COLORS.stopped; + form.status_color_stopped.value = DEFAULT_FOLDER_STATUS_COLORS.stopped; form.status_color_text.value = DEFAULT_FOLDER_STATUS_COLORS.text; if (typeof scheduleEditorRecalculation === 'function') { scheduleEditorRecalculation(0); } @@ -2273,6 +2269,7 @@ const validateForm = () => { validateParentFolderSelection(), validateRegexField(), validateFolderWebUiUrl(), + getFolderWebuiProfilesApi()?.validate() !== false, validateContextGraphTime(), validateHealthWarnThreshold(), validateHealthCriticalThreshold(), @@ -2953,7 +2950,7 @@ const initEditorChrome = () => {
    Started Paused - Stopped + StoppedText Accent
    @@ -3058,7 +3055,7 @@ const initEditorChrome = () => { updateMemberBulkMoveUi(); editorMode = loadEditorModePreference(); - activeEditorSection = normalizeActiveEditorSection('general', editorMode); + activeEditorSection = normalizeActiveEditorSection(activeEditorSection, editorMode); advancedSectionCollapsedState = loadAdvancedCollapseState(); $('#fvRegexSimulatorInput').off('input').on('input', updateRegexSimulator); $('#fvSuggestDefaults').off('click').on('click', suggestDefaultsFromMembers); @@ -3157,6 +3154,7 @@ const hydrateCurrentEditFolder = (folderRecord, folderRecordId, foldersMap = {}, setFieldValue('icon', normalizedFolder.icon); setFieldChecked('folder_webui', normalizedFolder.settings.folder_webui || false); setFieldValue('folder_webui_url', normalizedFolder.settings.folder_webui_url || ''); + getFolderWebuiProfilesApi()?.hydrate(normalizedFolder.settings.webui_profiles || normalizedFolder.settings.webuiProfiles || []); setFieldValue('preview', String(normalizedFolder.settings.preview)); setFieldValue('preview_rows', String(normalizePreviewRowLimit(normalizedFolder.settings, normalizedFolder))); setFieldValue('preview_overflow', normalizedFolder.settings.preview_overflow || normalizedFolder.settings.previewOverflow || 'default'); @@ -3195,7 +3193,7 @@ const hydrateCurrentEditFolder = (folderRecord, folderRecordId, foldersMap = {}, setFieldValue('folder_accent_color', normalizeHexColor(normalizedFolder.settings.folder_accent_color, DEFAULT_FOLDER_ACCENT_COLOR)); setFieldValue('status_color_started', normalizeHexColor(normalizedFolder.settings.status_color_started, DEFAULT_FOLDER_STATUS_COLORS.started)); setFieldValue('status_color_paused', normalizeHexColor(normalizedFolder.settings.status_color_paused, DEFAULT_FOLDER_STATUS_COLORS.paused)); - setFieldValue('status_color_stopped', normalizeHexColor(normalizedFolder.settings.status_color_stopped, DEFAULT_FOLDER_STATUS_COLORS.stopped)); + setFieldValue('status_color_stopped', normalizeHexColor(normalizedFolder.settings.status_color_stopped, DEFAULT_FOLDER_STATUS_COLORS.stopped)); setFieldValue('status_color_text', normalizeHexColor(normalizedFolder.settings.status_color_text, DEFAULT_FOLDER_STATUS_COLORS.text)); setFieldChecked('status_color_lock', normalizedFolder.settings.status_color_lock === true || normalizedFolder.settings.statusColorLock === true); setFieldValue('health_warn_stopped_percent', normalizedFolder.settings.health_warn_stopped_percent === undefined || normalizedFolder.settings.health_warn_stopped_percent === null @@ -3506,6 +3504,7 @@ const startFolderEditorRuntime = async () => { await bindIconPickerEvents(); updateList(); + getFolderWebuiProfilesApi()?.refreshMembers(); applySectionTags(); initEditorChrome(); updateForm(); @@ -3797,6 +3796,7 @@ const buildFolderPayloadFromForm = (e) => { settings: { folder_webui: e.folder_webui.checked, folder_webui_url: e.folder_webui_url.value.toString(), + webui_profiles: getFolderWebuiProfilesApi()?.serialize() || [], preview: parseInt(e.preview.value.toString()), preview_rows: normalizedPreviewRows, preview_overflow: ['expand_row', 'scroll'].includes(String(e.preview_overflow?.value)) ? String(e.preview_overflow.value) : 'default', @@ -3839,9 +3839,9 @@ const buildFolderPayloadFromForm = (e) => { dropdown_hover_color: normalizeHexColor(e.dropdown_hover_color.value.toString(), DEFAULT_DROPDOWN_HOVER_COLOR), folder_accent_enabled: e.folder_accent_enabled.checked, folder_accent_color: normalizeHexColor(e.folder_accent_color.value.toString(), DEFAULT_FOLDER_ACCENT_COLOR), - status_color_started: normalizeHexColor(e.status_color_started.value.toString(), DEFAULT_FOLDER_STATUS_COLORS.started), + status_color_started: normalizeHexColor(e.status_color_started.value.toString(), DEFAULT_FOLDER_STATUS_COLORS.started), status_color_started_explicit: true, status_color_paused: normalizeHexColor(e.status_color_paused.value.toString(), DEFAULT_FOLDER_STATUS_COLORS.paused), - status_color_stopped: normalizeHexColor(e.status_color_stopped.value.toString(), DEFAULT_FOLDER_STATUS_COLORS.stopped), + status_color_stopped: normalizeHexColor(e.status_color_stopped.value.toString(), DEFAULT_FOLDER_STATUS_COLORS.stopped), status_color_text: normalizeHexColor(e.status_color_text.value.toString(), DEFAULT_FOLDER_STATUS_COLORS.text), status_color_lock: e.status_color_lock?.checked === true, health_warn_stopped_percent: healthWarnThreshold, health_critical_stopped_percent: healthCriticalThreshold, diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.member-identity.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.member-identity.js index 5db8a342..5c33635e 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.member-identity.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.member-identity.js @@ -1,12 +1,12 @@ // @ts-check (function(root, factory) { if (typeof module === 'object' && module.exports) { - module.exports = factory(); + module.exports = factory(require('./folder.webui-profiles.js')); return; } - root.FolderViewPlusMemberIdentity = factory(); + root.FolderViewPlusMemberIdentity = factory(root.FolderViewPlusFoundationModules?.folderWebuiProfiles); root.FolderViewPlusMemberIdentityModuleLoaded = true; -}(typeof globalThis !== 'undefined' ? globalThis : this, function() { +}(typeof globalThis !== 'undefined' ? globalThis : this, function(webuiProfiles) { 'use strict'; const asObject = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {}; @@ -197,6 +197,7 @@ folder.memberIdentities = normalizeIdentityMap(folder.memberIdentities || folder.member_identities, safeType); folder.hiddenPreviewMembers = uniqueStrings(folder.hiddenPreviewMembers || folder.hidden_preview); folder.actions = Array.isArray(folder.actions) ? folder.actions.map((action) => ({ ...asObject(action) })) : []; + folder.settings = { ...asObject(folder.settings) }; folder.containers.forEach((name) => { if (liveNames.has(name)) { claimedNames.add(name); @@ -234,6 +235,9 @@ folder.containers = renameListEntry(folder.containers, oldName, newName); folder.hiddenPreviewMembers = renameListEntry(folder.hiddenPreviewMembers, oldName, newName); folder.actions = renameActionTargets(folder.actions, oldName, newName); + if (typeof webuiProfiles?.renameProfileMember === 'function') { + folder.settings.webui_profiles = webuiProfiles.renameProfileMember(folder.settings.webui_profiles, oldName, newName); + } delete folder.memberIdentities[oldName]; folder.memberIdentities[newName] = resolution.candidate.identity; renames[oldName] = newName; diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.settings-transfer.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.settings-transfer.js index 4b14c06a..8094ae35 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.settings-transfer.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.settings-transfer.js @@ -38,6 +38,8 @@ const normalizedSettings = source.settings && typeof source.settings === 'object' ? deepClone(source.settings) || {} : {}; + delete normalizedSettings.webui_profiles; + delete normalizedSettings.webuiProfiles; const actions = Array.isArray(source.actions) ? source.actions : []; const normalizedActions = []; let droppedMemberBoundActionCount = 0; diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.webui-profiles.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.webui-profiles.js new file mode 100644 index 00000000..6db80b2c --- /dev/null +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.webui-profiles.js @@ -0,0 +1,446 @@ +// @ts-check +(function(root, factory) { + if (typeof module === 'object' && module.exports) { + module.exports = factory(); + return; + } + const modules = root.FolderViewPlusFoundationModules = root.FolderViewPlusFoundationModules || {}; + modules.folderWebuiProfiles = factory(); +}(typeof globalThis !== 'undefined' ? globalThis : this, function() { + 'use strict'; + + const MAX_PROFILES = 100; + const MAX_PROFILE_MEMBERS = 250; + const MAX_PROFILE_NAME_LENGTH = 80; + const MAX_MEMBER_NAME_LENGTH = 160; + const PROFILE_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/; + const isObject = (value) => value && typeof value === 'object' && !Array.isArray(value); + const cleanText = (value, maxLength) => String(value ?? '') + .replace(/[\u0000-\u001f\u007f]/g, '') + .trim() + .slice(0, maxLength); + const uniqueStrings = (value, maxItems = MAX_PROFILE_MEMBERS) => { + const seen = new Set(); + const result = []; + for (const entry of Array.isArray(value) ? value : []) { + const text = cleanText(entry, MAX_MEMBER_NAME_LENGTH); + if (!text || seen.has(text)) continue; + seen.add(text); + result.push(text); + if (result.length >= maxItems) break; + } + return result; + }; + const createProfileId = (win = null) => { + try { + const generated = win?.crypto?.randomUUID?.(); + if (generated && PROFILE_ID_PATTERN.test(generated)) return generated; + } catch (_error) { + // Fall through to a locally unique identifier. + } + return `profile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + }; + const normalizeProfiles = (value, options = {}) => { + const source = Array.isArray(value) ? value : []; + const maxProfiles = Math.max(1, Math.min(MAX_PROFILES, Number(options.maxProfiles) || MAX_PROFILES)); + const usedIds = new Set(); + const result = []; + for (let index = 0; index < source.length && result.length < maxProfiles; index += 1) { + const entry = isObject(source[index]) ? source[index] : null; + if (!entry) continue; + let id = cleanText(entry.id, 64); + if (!PROFILE_ID_PATTERN.test(id)) id = `profile-${index + 1}`; + const baseId = id; + let suffix = 2; + while (usedIds.has(id)) { + id = `${baseId.slice(0, 58)}-${suffix}`; + suffix += 1; + } + usedIds.add(id); + result.push({ + id, + name: cleanText(entry.name, MAX_PROFILE_NAME_LENGTH) || `Profile ${result.length + 1}`, + containers: uniqueStrings(entry.containers || entry.members) + }); + } + return result; + }; + const stripProfilesFromSettings = (settings) => { + const next = isObject(settings) ? JSON.parse(JSON.stringify(settings)) : {}; + delete next.webui_profiles; + delete next.webuiProfiles; + return next; + }; + const renameProfileMember = (profiles, oldName, newName) => normalizeProfiles(profiles).map((profile) => ({ + ...profile, + containers: uniqueStrings(profile.containers.map((name) => name === oldName ? newName : name)) + })); + const getRuntimeEntryName = (fallbackName, entry) => cleanText( + entry?.name || entry?.Name || entry?.info?.Name || fallbackName, + MAX_MEMBER_NAME_LENGTH + ); + const collectProfileTargets = (profile, runtimeEntries, options = {}) => { + const normalized = normalizeProfiles([profile])[0] || null; + if (!normalized) return { profile: null, urls: [], selectedCount: 0, eligibleCount: 0, unavailableCount: 0 }; + const selected = new Set(normalized.containers); + const matched = new Set(); + const urls = []; + const getSafeWebuiUrl = typeof options.getSafeWebuiUrl === 'function' + ? options.getSafeWebuiUrl + : ((url) => cleanText(url, 2048)); + const runningOnly = options.runningOnly !== false; + Object.entries(isObject(runtimeEntries) ? runtimeEntries : {}).forEach(([fallbackName, entry]) => { + const name = getRuntimeEntryName(fallbackName, entry); + if (!name || !selected.has(name)) return; + matched.add(name); + const running = entry?.state === true && entry?.pause !== true; + const url = getSafeWebuiUrl(entry?.webui || entry?.WebUi || entry?.WebUI || ''); + if (url && (!runningOnly || running)) urls.push(url); + }); + const dedupedUrls = Array.from(new Set(urls)); + return { + profile: normalized, + urls: dedupedUrls, + selectedCount: normalized.containers.length, + eligibleCount: dedupedUrls.length, + unavailableCount: Math.max(0, normalized.containers.length - matched.size) + }; + }; + const summarizeProfiles = (profiles, availableMemberNames = []) => { + const available = new Set(uniqueStrings(availableMemberNames, 5000)); + const normalized = normalizeProfiles(profiles); + let selectedReferenceCount = 0; + let unavailableReferenceCount = 0; + normalized.forEach((profile) => { + selectedReferenceCount += profile.containers.length; + unavailableReferenceCount += profile.containers.filter((name) => !available.has(name)).length; + }); + return { + profileCount: normalized.length, + selectedReferenceCount, + unavailableReferenceCount, + duplicateProfileIdCount: 0, + invalidProfileCount: 0 + }; + }; + const resolveProfileLaunch = (settings, profileId, runtimeEntries, options = {}) => { + const profile = normalizeProfiles(settings?.webui_profiles || settings?.webuiProfiles) + .find((entry) => entry.id === cleanText(profileId, 64)) || null; + return profile ? collectProfileTargets(profile, runtimeEntries, options) : null; + }; + const appendRuntimeMenuItems = (target, options = {}) => { + const profiles = normalizeProfiles(options.profiles); + if (!Array.isArray(target) || profiles.length === 0) return false; + const translate = typeof options.translate === 'function' ? options.translate : ((_key, fallback) => fallback); + const onOpen = typeof options.onOpen === 'function' ? options.onOpen : (() => {}); + const onManage = typeof options.onManage === 'function' ? options.onManage : (() => {}); + target.push({ + text: translate('docker.webui-profiles.open', 'Open WebUI profile'), + icon: 'fa-list-alt', + subMenu: profiles.map((profile) => { + const result = collectProfileTargets(profile, options.runtimeEntries, { + runningOnly: true, + getSafeWebuiUrl: options.getSafeWebuiUrl + }); + return { + text: translate('docker.webui-profiles.ready', '$1 ($2 of $3 ready)', profile.name, result.eligibleCount, result.selectedCount), + icon: 'fa-external-link', + disabled: result.eligibleCount === 0, + action: (event) => { event?.preventDefault?.(); onOpen(profile.id); } + }; + }) + }, { + text: translate('docker.webui-profiles.manage', 'Manage WebUI profiles'), + icon: 'fa-sliders', + action: (event) => { event?.preventDefault?.(); onManage(); } + }); + return true; + }; + + const createEditorApi = (deps = {}) => { + const win = deps.window || (typeof window !== 'undefined' ? window : null); + const doc = deps.document || win?.document || null; + const form = deps.form || doc?.getElementById?.('fvFolderEditorForm') || null; + const getMembers = typeof deps.getMembers === 'function' ? deps.getMembers : (() => []); + const translate = typeof deps.translate === 'function' ? deps.translate : ((_key, fallback) => fallback); + const onChange = typeof deps.onChange === 'function' ? deps.onChange : (() => {}); + const type = cleanText(deps.type, 16).toLowerCase() === 'docker' ? 'docker' : 'vm'; + const host = form?.querySelector?.('#fvWebuiProfilesWorkspace') || null; + const hiddenInput = form?.querySelector?.('input[name="webui_profiles"]') || null; + let profiles = []; + let disposed = false; + let memberRefreshTimer = 0; + + const readIncludedMembers = () => { + const allMembers = new Map((Array.isArray(getMembers()) ? getMembers() : []) + .map((member) => [cleanText(member?.Name || member?.name, MAX_MEMBER_NAME_LENGTH), member]) + .filter(([name]) => Boolean(name))); + const names = Array.from(form?.querySelectorAll?.('input[name*="containers"]:checked') || []) + .map((input) => cleanText(input.value, MAX_MEMBER_NAME_LENGTH)) + .filter(Boolean); + return names.map((name) => { + const member = allMembers.get(name) || {}; + const rawState = member?.State || member?.RawState || {}; + const state = rawState?.Paused === true + ? 'paused' + : (rawState?.Running === true ? 'running' : 'stopped'); + return { + name, + state, + hasWebui: Boolean(member?.WebUi || member?.WebUI || member?.webui || member?.webuiCapability === true) + }; + }); + }; + const writeHiddenInput = (notify = true) => { + if (!hiddenInput) return; + hiddenInput.value = JSON.stringify(profiles.map((profile) => ({ + id: profile.id, + name: cleanText(profile.name, MAX_PROFILE_NAME_LENGTH), + containers: uniqueStrings(profile.containers) + }))); + if (notify) { + hiddenInput.dispatchEvent(new win.Event('change', { bubbles: true })); + onChange(profiles); + } + }; + const button = (label, icon, action, className = '') => { + const node = doc.createElement('button'); + node.type = 'button'; + node.className = `fv-webui-profile-button ${className}`.trim(); + node.setAttribute('aria-label', label); + const glyph = doc.createElement('i'); + glyph.className = `fa ${icon}`; + glyph.setAttribute('aria-hidden', 'true'); + const text = doc.createElement('span'); + text.textContent = label; + node.append(glyph, text); + node.addEventListener('click', action); + return node; + }; + const setStatus = (message = '', tone = '') => { + const node = host?.querySelector?.('.fv-webui-profiles-status'); + if (!node) return; + node.textContent = message; + node.className = `fv-webui-profiles-status${tone ? ` is-${tone}` : ''}`; + }; + const validate = () => { + if (type !== 'docker') return true; + const names = new Set(); + let message = ''; + profiles.forEach((profile) => { + const name = cleanText(profile.name, MAX_PROFILE_NAME_LENGTH); + const key = name.toLocaleLowerCase(); + if (!message && !name) message = translate('editor.webui-profiles.error-name', 'Every WebUI profile needs a name.'); + if (!message && names.has(key)) message = translate('editor.webui-profiles.error-duplicate', 'WebUI profile names must be unique within this folder.'); + names.add(key); + if (!message && uniqueStrings(profile.containers).length === 0) { + message = translate('editor.webui-profiles.error-members', 'Every WebUI profile must select at least one folder member.'); + } + }); + Array.from(host?.querySelectorAll?.('.fv-webui-profile-name') || []).forEach((input) => { + const name = cleanText(input.value, MAX_PROFILE_NAME_LENGTH).toLocaleLowerCase(); + const count = profiles.filter((profile) => cleanText(profile.name, MAX_PROFILE_NAME_LENGTH).toLocaleLowerCase() === name).length; + input.classList.toggle('fv-input-error', !name || count > 1); + input.setAttribute('aria-invalid', !name || count > 1 ? 'true' : 'false'); + }); + setStatus(message, message ? 'error' : 'ready'); + return !message; + }; + const renderMemberRows = (profile, list, query = '') => { + const members = readIncludedMembers(); + const availableNames = new Set(members.map((member) => member.name)); + profile.containers.forEach((name) => { + if (!availableNames.has(name)) members.push({ name, state: 'unavailable', hasWebui: false }); + }); + const normalizedQuery = cleanText(query, 160).toLocaleLowerCase(); + list.replaceChildren(); + members.forEach((member) => { + if (normalizedQuery && !member.name.toLocaleLowerCase().includes(normalizedQuery)) return; + const row = doc.createElement('label'); + row.className = `fv-webui-profile-member is-${member.state}`; + const checkbox = doc.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = profile.containers.includes(member.name); + checkbox.value = member.name; + checkbox.addEventListener('change', () => { + profile.containers = checkbox.checked + ? uniqueStrings([...profile.containers, member.name]) + : profile.containers.filter((name) => name !== member.name); + writeHiddenInput(); + validate(); + }); + const name = doc.createElement('strong'); + name.textContent = member.name; + const meta = doc.createElement('span'); + const webuiLabel = member.hasWebui + ? translate('editor.webui-profiles.webui-ready', 'WebUI configured') + : translate('editor.webui-profiles.webui-runtime', 'WebUI checked at launch'); + meta.textContent = member.state === 'unavailable' + ? translate('editor.webui-profiles.unavailable', 'Unavailable in this folder') + : `${member.state} · ${webuiLabel}`; + row.append(checkbox, name, meta); + list.appendChild(row); + }); + if (!list.children.length) { + const empty = doc.createElement('p'); + empty.className = 'fv-webui-profile-empty-members'; + empty.textContent = translate('editor.webui-profiles.no-members', 'No matching folder members are available.'); + list.appendChild(empty); + } + }; + const renderProfile = (profile, index) => { + const card = doc.createElement('article'); + card.className = 'fv-webui-profile-card'; + card.dataset.profileId = profile.id; + const header = doc.createElement('div'); + header.className = 'fv-webui-profile-head'; + const nameInput = doc.createElement('input'); + nameInput.type = 'text'; + nameInput.className = 'fv-webui-profile-name'; + nameInput.maxLength = MAX_PROFILE_NAME_LENGTH; + nameInput.value = profile.name; + nameInput.setAttribute('aria-label', translate('editor.webui-profiles.name', 'Profile name')); + nameInput.addEventListener('input', () => { + profile.name = nameInput.value; + writeHiddenInput(); + validate(); + }); + const actions = doc.createElement('div'); + actions.className = 'fv-webui-profile-actions'; + actions.append( + button(translate('editor.webui-profiles.duplicate', 'Duplicate'), 'fa-copy', () => { + if (profiles.length >= MAX_PROFILES) return; + profiles.splice(index + 1, 0, { + id: createProfileId(win), + name: `${cleanText(profile.name, 68) || 'Profile'} Copy`, + containers: [...profile.containers] + }); + writeHiddenInput(); + render(); + }), + button(translate('editor.webui-profiles.move-up', 'Move up'), 'fa-chevron-up', () => { + if (index <= 0) return; + [profiles[index - 1], profiles[index]] = [profiles[index], profiles[index - 1]]; + writeHiddenInput(); + render(); + }, 'is-icon'), + button(translate('editor.webui-profiles.move-down', 'Move down'), 'fa-chevron-down', () => { + if (index >= profiles.length - 1) return; + [profiles[index + 1], profiles[index]] = [profiles[index], profiles[index + 1]]; + writeHiddenInput(); + render(); + }, 'is-icon'), + button(translate('editor.webui-profiles.delete', 'Delete'), 'fa-trash', () => { + profiles.splice(index, 1); + writeHiddenInput(); + render(); + }, 'is-danger') + ); + header.append(nameInput, actions); + const tools = doc.createElement('div'); + tools.className = 'fv-webui-profile-tools'; + const search = doc.createElement('input'); + search.type = 'search'; + search.placeholder = translate('editor.webui-profiles.search', 'Search folder members'); + search.setAttribute('aria-label', search.placeholder); + const list = doc.createElement('div'); + list.className = 'fv-webui-profile-members'; + search.addEventListener('input', () => renderMemberRows(profile, list, search.value)); + tools.append( + search, + button(translate('editor.webui-profiles.select-webui', 'Select all with WebUI'), 'fa-check-square-o', () => { + profile.containers = uniqueStrings(readIncludedMembers().filter((member) => member.hasWebui).map((member) => member.name)); + writeHiddenInput(); + render(); + }), + button(translate('editor.webui-profiles.clear', 'Clear selection'), 'fa-square-o', () => { + profile.containers = []; + writeHiddenInput(); + render(); + }) + ); + renderMemberRows(profile, list); + card.append(header, tools, list); + return card; + }; + const render = () => { + if (!host || disposed) return; + host.replaceChildren(); + if (type !== 'docker') { + host.closest?.('.basic')?.setAttribute?.('hidden', 'hidden'); + return; + } + const toolbar = doc.createElement('div'); + toolbar.className = 'fv-webui-profiles-toolbar'; + const summary = doc.createElement('p'); + summary.textContent = profiles.length + ? translate('editor.webui-profiles.count', '$1 saved profile(s)', profiles.length).replace('$1', String(profiles.length)) + : translate('editor.webui-profiles.empty', 'No custom WebUI profiles yet.'); + const add = button(translate('editor.webui-profiles.add', 'Add profile'), 'fa-plus', () => { + if (profiles.length >= MAX_PROFILES) { + setStatus(translate('editor.webui-profiles.limit', 'This folder has reached the WebUI profile safety limit.'), 'error'); + return; + } + profiles.push({ id: createProfileId(win), name: `Profile ${profiles.length + 1}`, containers: [] }); + writeHiddenInput(); + render(); + }, 'is-primary'); + toolbar.append(summary, add); + host.appendChild(toolbar); + const cards = doc.createElement('div'); + cards.className = 'fv-webui-profile-list'; + profiles.forEach((profile, index) => cards.appendChild(renderProfile(profile, index))); + host.appendChild(cards); + const status = doc.createElement('p'); + status.className = 'fv-webui-profiles-status'; + status.setAttribute('aria-live', 'polite'); + host.appendChild(status); + validate(); + }; + const hydrate = (value) => { + profiles = normalizeProfiles(value); + writeHiddenInput(false); + render(); + }; + const scheduleMemberRefresh = () => { + if (memberRefreshTimer) win.clearTimeout(memberRefreshTimer); + memberRefreshTimer = win.setTimeout(() => { + memberRefreshTimer = 0; + render(); + }, 0); + }; + const memberChangeHandler = (event) => { + if (event?.target?.matches?.('input[name*="containers"]')) scheduleMemberRefresh(); + }; + form?.addEventListener?.('change', memberChangeHandler); + hydrate([]); + return Object.freeze({ + hydrate, + render, + refreshMembers: scheduleMemberRefresh, + serialize: () => normalizeProfiles(profiles), + validate, + snapshot: () => ({ profiles: normalizeProfiles(profiles), ...summarizeProfiles(profiles, readIncludedMembers().map((member) => member.name)) }), + dispose: () => { + disposed = true; + if (memberRefreshTimer) win.clearTimeout(memberRefreshTimer); + form?.removeEventListener?.('change', memberChangeHandler); + host?.replaceChildren?.(); + } + }); + }; + + return Object.freeze({ + MAX_PROFILES, + MAX_PROFILE_MEMBERS, + MAX_PROFILE_NAME_LENGTH, + normalizeProfiles, + stripProfilesFromSettings, + renameProfileMember, + collectProfileTargets, + summarizeProfiles, + resolveProfileLaunch, + appendRuntimeMenuItems, + createEditorApi + }); +})); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.folder-contract.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.folder-contract.js index 827f214c..a87d8d91 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.folder-contract.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.folder-contract.js @@ -3,10 +3,12 @@ 'use strict'; const DEFAULT_FOLDER_STATUS_COLORS = Object.freeze({ - started: '#ffffff', + started: '#55b72d', paused: '#b8860b', - stopped: '#ff4d4d' + stopped: '#ff4d4d', + text: '#ffffff' }); + const LEGACY_DEFAULT_FOLDER_STARTED_COLOR = '#ffffff'; const DEFAULT_FOLDER_ACCENT_COLOR = '#ffca63'; const DEFAULT_PREVIEW_BORDER_COLOR = '#afa89e'; const DEFAULT_PREVIEW_BORDER_WIDTH = 1; @@ -141,6 +143,7 @@ window.FolderViewPlusFolderContract = { DEFAULT_FOLDER_STATUS_COLORS, + LEGACY_DEFAULT_FOLDER_STARTED_COLOR, DEFAULT_FOLDER_ACCENT_COLOR, DEFAULT_PREVIEW_BORDER_COLOR, DEFAULT_PREVIEW_BORDER_WIDTH, diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.starter-templates.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.starter-templates.js index cc88dd85..5dfc619e 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.starter-templates.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.starter-templates.js @@ -566,9 +566,9 @@ const buildStarterFolderPayload = (name, iconPath = DEFAULT_STARTER_FOLDER_ICON) preview_vertical_bars_color: '#afa89e', folder_accent_enabled: false, folder_accent_color: '#ffca63', - status_color_started: '#ffffff', + status_color_started: '#55b72d', status_color_paused: '#b8860b', - status_color_stopped: '#ff4d4d', + status_color_stopped: '#ff4d4d', status_color_text: '#ffffff', health_warn_stopped_percent: '', health_critical_stopped_percent: '', health_profile: '', diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.ui.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.ui.js index 50d6ba6d..263f1cb6 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.ui.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.ui.js @@ -41,6 +41,8 @@ 'check-circle': '', 'clock': '', 'document': '', + 'eye': '', + 'eye-off': '', 'folder': '', 'globe': '', 'heart': '', diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-normalization.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-normalization.js index 7a6c1c1e..35c22d11 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-normalization.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-normalization.js @@ -28,9 +28,10 @@ const PERFORMANCE_PROFILE_OPTIONS = ['standard', 'adaptive', 'maximum']; const RUNTIME_PAGE_VIEW_MODE_OPTIONS = ['folderview', 'host', 'command']; const DEFAULT_FOLDER_STATUS_COLORS = { - started: '#ffffff', + started: '#55b72d', paused: '#b8860b', - stopped: '#ff4d4d' + stopped: '#ff4d4d', + text: '#ffffff' }; const DEFAULT_HEALTH_PREFS = { cardsEnabled: true, @@ -105,11 +106,10 @@ } = utilityFoundation; const getFolderStatusColors = (settings) => { - const source = isPlainObject(settings) ? settings : {}; - return { - started: normalizeHexColor(source.status_color_started, DEFAULT_FOLDER_STATUS_COLORS.started), + const source = isPlainObject(settings) ? settings : {}; const normalizedStarted = normalizeHexColor(source.status_color_started, DEFAULT_FOLDER_STATUS_COLORS.started); const startedExplicit = source.status_color_started_explicit === true || source.statusColorStartedExplicit === true; + return { started: !startedExplicit && normalizedStarted === '#ffffff' ? DEFAULT_FOLDER_STATUS_COLORS.started : normalizedStarted, paused: normalizeHexColor(source.status_color_paused, DEFAULT_FOLDER_STATUS_COLORS.paused), - stopped: normalizeHexColor(source.status_color_stopped, DEFAULT_FOLDER_STATUS_COLORS.stopped) + stopped: normalizeHexColor(source.status_color_stopped, DEFAULT_FOLDER_STATUS_COLORS.stopped), text: normalizeHexColor(source.status_color_text, DEFAULT_FOLDER_STATUS_COLORS.text) }; }; @@ -243,7 +243,7 @@ return Array.from( new Set( value - .map((item) => String(item || '').trim()) + .filter((item) => typeof item === 'string').map((item) => item.trim()) .filter((item) => item !== '') ) ); @@ -279,7 +279,7 @@ return null; } - const name = String(value.name || '').trim(); + const name = typeof value.name === 'string' ? value.name.trim() : ''; if (name === '') { return null; } diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-prefs.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-prefs.js index 42fddae9..b636cc65 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-prefs.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-prefs.js @@ -85,8 +85,8 @@ const folderDefaultsSettings = isPlainObject(folderDefaultsProfileRaw.settings) ? JSON.parse(JSON.stringify(folderDefaultsProfileRaw.settings)) : {}; - const folderDefaultsActionsRaw = Array.isArray(folderDefaultsProfileRaw.actions) ? folderDefaultsProfileRaw.actions : []; - const folderDefaultsActions = []; + delete folderDefaultsSettings.webui_profiles; delete folderDefaultsSettings.webuiProfiles; + const folderDefaultsActionsRaw = Array.isArray(folderDefaultsProfileRaw.actions) ? folderDefaultsProfileRaw.actions : [], folderDefaultsActions = []; folderDefaultsActionsRaw.forEach((entry) => { if (!isPlainObject(entry)) { return; @@ -318,6 +318,7 @@ ) }; const pinnedFolderIds = normalizeStringIdList(incoming.pinnedFolderIds); + const hiddenFolderIds = normalizeStringIdList(incoming.hiddenFolderIds); const expandedFolderState = normalizeExpandedFolderStateMap(incoming.expandedFolderState); const hideEmptyFolders = incoming.hideEmptyFolders === true; const appColumnWidth = normalizeAppColumnWidth(incoming.appColumnWidth); @@ -346,6 +347,7 @@ sortMode, manualOrder, pinnedFolderIds, + hiddenFolderIds, expandedFolderState, hideEmptyFolders, appColumnWidth, diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.page-diagnostics.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.page-diagnostics.js index f226dc59..0b299692 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.page-diagnostics.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.page-diagnostics.js @@ -78,8 +78,8 @@ highContrast: snapshot?.appearance?.highContrast === true }, state: { - visibleRows: boundedCount(snapshot?.state?.visibleRows), - folderRows: boundedCount(snapshot?.state?.folderRows), + visibleRows: boundedCount(snapshot?.state?.visibleRows), folderRows: boundedCount(snapshot?.state?.folderRows), + hiddenFolderSelections: boundedCount(snapshot?.state?.hiddenFolderSelections), effectivelyHiddenFolders: boundedCount(snapshot?.state?.effectivelyHiddenFolders), revealedHiddenFolderRows: boundedCount(snapshot?.state?.revealedHiddenFolderRows), revealHiddenFolders: snapshot?.state?.revealHiddenFolders === true, expandedFolders: boundedCount(snapshot?.state?.expandedFolders), visibleMembers: boundedCount(snapshot?.state?.visibleMembers), loadingIndicators: boundedCount(snapshot?.state?.loadingIndicators), @@ -152,8 +152,8 @@ highContrast: matchesMedia(win, '(prefers-contrast: more)') }, state: { - visibleRows: countVisible(host, 'tr', win), - folderRows: countVisible(host, '.folder, .folder-showcase-outer', win), + visibleRows: countVisible(host, 'tr', win), folderRows: countVisible(host, '.folder, .folder-showcase-outer', win), + hiddenFolderSelections: boundedCount(doc?.body?.getAttribute?.('data-fvplus-docker-hidden-explicit')), effectivelyHiddenFolders: boundedCount(doc?.body?.getAttribute?.('data-fvplus-docker-hidden-effective')), revealedHiddenFolderRows: countVisible(host, '.folder.fv-folder-hidden-revealed', win), revealHiddenFolders: doc?.body?.getAttribute?.('data-fvplus-docker-hidden-reveal') === 'true', expandedFolders: countVisible(host, '.folder[expanded="true"], .folder-showcase-outer[expanded="true"]', win), visibleMembers: countVisible(host, '.folder-preview-wrapper, .folder-element-docker, .folder-element-vm', win), loadingIndicators: countVisible(host, '.spinner, .fv-runtime-loading-row, [aria-busy="true"]', win), diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.shared-primitives.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.shared-primitives.js index 58bdfbc1..a415cbdc 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.shared-primitives.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.shared-primitives.js @@ -114,6 +114,7 @@ const clearClassName = String(options.clearClassName || 'fvplus-docker-quick-clear'); const labelSet = options.labelSet instanceof Set ? options.labelSet : new Set(); const iconClassCandidates = Array.isArray(options.iconClassCandidates) ? options.iconClassCandidates : []; + const minimumItems = Math.max(1, Number(options.minimumItems) || 3), maximumItems = Math.max(minimumItems, Number(options.maximumItems) || minimumItems); const selectors = Array.isArray(options.selectors) && options.selectors.length ? options.selectors : [ @@ -167,10 +168,8 @@ if (!$menu || !$menu.length) { return false; } - const $quickItems = $menu.children('li').filter((_, item) => isQuickItem($(item))).slice(0, 3); - if ($quickItems.length < 3) { - return false; - } + const $quickItems = $menu.children('li').filter((_, item) => isQuickItem($(item))).slice(0, maximumItems); + if ($quickItems.length < minimumItems) return false; $menu.addClass(menuClassName); $quickItems.each((_, item) => { const $item = $(item); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js index 5b274cb9..15635566 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/vm.js @@ -22,9 +22,9 @@ const applyVmThemeResolverTokens = (reason = 'vm-runtime:initial', options = {}) ); const DEFAULT_FOLDER_ICON_PATH = '/plugins/folderview.plus/images/folder-icon.png'; const localDefaultFolderStatusColors = runtimeShared.DEFAULT_FOLDER_STATUS_COLORS || { - started: '#ffffff', + started: '#55b72d', paused: '#b8860b', - stopped: '#ff4d4d' + stopped: '#ff4d4d', text: '#ffffff' }; const normalizeStatusHexColor = typeof runtimeShared.normalizeStatusHexColor === 'function' ? runtimeShared.normalizeStatusHexColor diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-integrity.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-integrity.php index de8c1ce6..0ac0f66b 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-integrity.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-integrity.php @@ -88,7 +88,7 @@ function diagnosticsFolderStatusColors(array $folder): array { return [ 'started' => diagnosticsNormalizeStatusColor($settings['status_color_started'] ?? null, FVPLUS_DEFAULT_FOLDER_STATUS_COLORS['started']), 'paused' => diagnosticsNormalizeStatusColor($settings['status_color_paused'] ?? null, FVPLUS_DEFAULT_FOLDER_STATUS_COLORS['paused']), - 'stopped' => diagnosticsNormalizeStatusColor($settings['status_color_stopped'] ?? null, FVPLUS_DEFAULT_FOLDER_STATUS_COLORS['stopped']) + 'stopped' => diagnosticsNormalizeStatusColor($settings['status_color_stopped'] ?? null, FVPLUS_DEFAULT_FOLDER_STATUS_COLORS['stopped']), 'text' => diagnosticsNormalizeStatusColor($settings['status_color_text'] ?? null, FVPLUS_DEFAULT_FOLDER_STATUS_COLORS['text']) ]; } @@ -664,13 +664,8 @@ function diagnosticsBuildIntegrityChecks(string $type, array $folders, array $pr $missingManualOrderIds[] = $manualId; } } - $missingPinnedFolderIds = []; - foreach (($prefs['pinnedFolderIds'] ?? []) as $pinnedId) { - $pinnedId = (string)$pinnedId; - if ($pinnedId !== '' && !array_key_exists($pinnedId, $folders)) { - $missingPinnedFolderIds[] = $pinnedId; - } - } + $missingPreferenceFolderIds = static fn($ids): array => array_values(array_filter(normalizeStringIdList($ids), static fn($id): bool => !array_key_exists((string)$id, $folders))); + $missingPinnedFolderIds = $missingPreferenceFolderIds($prefs['pinnedFolderIds'] ?? []); $missingHiddenFolderIds = $missingPreferenceFolderIds($prefs['hiddenFolderIds'] ?? []); $pathHealth = diagnosticsBuildPathHealth($type, $privacyMode); $pathIssueCount = count($pathHealth['issues'] ?? []); @@ -693,6 +688,7 @@ function diagnosticsBuildIntegrityChecks(string $type, array $folders, array $pr + count($invalidRules) + count($missingManualOrderIds) + count($missingPinnedFolderIds) + + count($missingHiddenFolderIds) + $orphanedCount + $buildConflicts($effectiveAssignments)['count'] + $pathIssueCount @@ -729,6 +725,10 @@ function diagnosticsBuildIntegrityChecks(string $type, array $folders, array $pr 'count' => count($missingPinnedFolderIds), 'ids' => array_values(array_unique($missingPinnedFolderIds)) ], + 'missingHiddenFolderIds' => [ + 'count' => count($missingHiddenFolderIds), + 'ids' => array_values(array_unique($missingHiddenFolderIds)) + ], 'duplicateAssignments' => [ 'explicit' => $buildConflicts($explicitAssignments), 'regex' => $buildConflicts($regexAssignments), diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-summary.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-summary.php index d34de629..a08ae811 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-summary.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-summary.php @@ -1,4 +1,4 @@ - 0, 'paused' => 0, 'stopped' => 0, 'total' => 0]; $entityStateCounts = ['started' => 0, 'paused' => 0, 'stopped' => 0]; $updateCounts = ['available' => 0, 'upToDate' => 0, 'unknown' => 0, 'total' => 0]; - $assignedItemSet = []; - + $assignedItemSet = []; $webuiProfileDiagnostics = fvplusMergeWebuiProfileDiagnostics([], []); foreach ($infoByName as $name => $item) { if (!is_array($item)) { continue; @@ -135,7 +134,9 @@ function diagnosticsBuildStateSnapshot(string $type, array $folders, array $pref $members[] = $name; } } - + if ($type === 'docker') { + $webuiProfileDiagnostics = fvplusMergeWebuiProfileDiagnostics($webuiProfileDiagnostics, fvplusBuildWebuiProfileDiagnostics($folder['settings'] ?? [], $members)); + } $started = 0; $paused = 0; $stopped = 0; @@ -233,7 +234,6 @@ function diagnosticsBuildStateSnapshot(string $type, array $folders, array $pref ); } } - $entityDetails = []; $entityDetailsTotal = 0; $entityDetailsMaxEntries = 200; @@ -306,6 +306,7 @@ function diagnosticsBuildStateSnapshot(string $type, array $folders, array $pref 'maxDepth' => $maxDepth, 'updateCounts' => $updateCounts, 'managerCounts' => $managerCounts, + 'webuiProfiles' => $webuiProfileDiagnostics, 'entityDetails' => [ 'total' => $entityDetailsTotal, 'maxEntries' => $entityDetailsMaxEntries, @@ -415,14 +416,12 @@ function diagnosticsBuildIntegrityIssueDetail(array $integrity): string { ); } - $missingPinnedFolderIds = max(0, (int)($integrity['missingPinnedFolderIds']['count'] ?? 0)); - if ($missingPinnedFolderIds > 0) { - return sprintf( - '%d pinned folder id%s no longer %s an existing folder.', - $missingPinnedFolderIds, - $missingPinnedFolderIds === 1 ? '' : 's', - $missingPinnedFolderIds === 1 ? 'matches' : 'match' - ); + foreach ([['missingPinnedFolderIds', 'pinned'], ['missingHiddenFolderIds', 'hidden']] as [$key, $label]) { + $missingFolderIds = max(0, (int)($integrity[$key]['count'] ?? 0)); + if ($missingFolderIds > 0) { + return sprintf('%d %s folder id%s no longer %s an existing folder.', $missingFolderIds, $label, + $missingFolderIds === 1 ? '' : 's', $missingFolderIds === 1 ? 'matches' : 'match'); + } } $duplicateNameCount = max(0, (int)($integrity['duplicateFolderNames']['count'] ?? 0)); @@ -588,6 +587,7 @@ function diagnosticsBuildRecommendedActions(array $typesData, array $customIcons || ((int)($integrity['invalidFolderRegex']['count'] ?? 0)) > 0 || ((int)($integrity['invalidFolderIconPaths']['count'] ?? 0)) > 0 || ((int)($integrity['missingPinnedFolderIds']['count'] ?? 0)) > 0 + || ((int)($integrity['missingHiddenFolderIds']['count'] ?? 0)) > 0 || ((int)($integrity['missingManualOrderIds']['count'] ?? 0)) > 0; if ($prefsNeedCleanup) { break; diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-support-bundle.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-support-bundle.php index 53ecd59b..e4aee60e 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-support-bundle.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-support-bundle.php @@ -240,12 +240,8 @@ function diagnosticsBuildSupportBundlePluginTypeSection(string $type, array $typ 'pluginState.' . $type . '.prefs.expandedFolderState', is_array($typeData['expandedFolderState'] ?? null) ? $typeData['expandedFolderState'] : [] ), - 'pinnedFolders' => diagnosticsSupportBundleRedactFolderIdList( - $redactor, - $type, - 'pluginState.' . $type . '.prefs.pinnedFolders.*', - is_array($typeData['pinnedFolderIds'] ?? null) ? $typeData['pinnedFolderIds'] : [] - ), + 'pinnedFolders' => diagnosticsSupportBundleRedactFolderIdList($redactor, $type, 'pluginState.' . $type . '.prefs.pinnedFolders.*', is_array($typeData['pinnedFolderIds'] ?? null) ? $typeData['pinnedFolderIds'] : []), + 'hiddenFolders' => diagnosticsSupportBundleRedactFolderIdList($redactor, $type, 'pluginState.' . $type . '.prefs.hiddenFolders.*', is_array($typeData['hiddenFolderIds'] ?? null) ? $typeData['hiddenFolderIds'] : []), 'hideEmptyFolders' => (bool)($typeData['hideEmptyFolders'] ?? false), 'appColumnWidth' => (string)($typeData['appColumnWidth'] ?? 'standard'), 'setupWizardCompleted' => (bool)($typeData['setupWizardCompleted'] ?? false), diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics.php index ee46244b..1f826ccc 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics.php @@ -158,8 +158,8 @@ function getDiagnosticsSnapshot(string $privacyMode = FVPLUS_DIAGNOSTICS_DEFAULT 'sortMode' => $prefs['sortMode'] ?? 'created', 'ruleCount' => count($prefs['autoRules'] ?? []), 'manualOrderCount' => count($prefs['manualOrder'] ?? []), - 'pinnedFolderCount' => count($prefs['pinnedFolderIds'] ?? []), - 'pinnedFolderIds' => normalizeStringIdList($prefs['pinnedFolderIds'] ?? []), + 'pinnedFolderCount' => count($prefs['pinnedFolderIds'] ?? []), 'pinnedFolderIds' => normalizeStringIdList($prefs['pinnedFolderIds'] ?? []), + 'hiddenFolderCount' => count($prefs['hiddenFolderIds'] ?? []), 'hiddenFolderIds' => normalizeStringIdList($prefs['hiddenFolderIds'] ?? []), 'expandedFolderState' => normalizeExpandedStateMap($prefs['expandedFolderState'] ?? []), 'hideEmptyFolders' => normalizeBool($prefs['hideEmptyFolders'] ?? false, false), 'appColumnWidth' => normalizeAppColumnWidth($prefs['appColumnWidth'] ?? 'standard'), diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.folder-mutations.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.folder-mutations.php index f21fdeb0..ddaafe27 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.folder-mutations.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.folder-mutations.php @@ -264,9 +264,7 @@ function applyFolderBatchOperations(string $type, array $operations, $expectedRe ]; }); } - - function applyFolderMemberIdentityPatches(string $type, array $patches): array { - $type = ensureType($type); + function applyFolderMemberIdentityPatches(string $type, array $patches): array { $type = ensureType($type); $fileData = readRawFolderMap($type); $changedFolderIds = []; $renameCount = 0; @@ -306,6 +304,9 @@ function applyFolderMemberIdentityPatches(string $type, array $patches): array { } } unset($action); + $profiles = fvplusNormalizeWebuiProfiles($folder['settings']['webui_profiles'] ?? []); + foreach ($profiles as &$profile) $profile['containers'] = array_values(array_unique(array_map(static fn($name) => $name === $oldName ? $newName : $name, $profile['containers']))); + unset($profile); $folder['settings']['webui_profiles'] = $profiles; if (is_array($folder['memberIdentities'][$oldName] ?? null)) { $folder['memberIdentities'][$newName] = $folder['memberIdentities'][$oldName]; unset($folder['memberIdentities'][$oldName]); @@ -356,7 +357,6 @@ function applyFolderMemberIdentityPatches(string $type, array $patches): array { 'metadata' => readConfigMetadata($type, true) ]; } - function applyFolderSettingsPayload(string $type, array $targetIds, array $settingsPayload): array { $type = ensureType($type); $normalizedSettings = normalizeFolderSettingsTransferPayload($settingsPayload); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php index d165678c..f0649c57 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.php @@ -399,9 +399,10 @@ function fv3_get_tailscale_fqdn_from_container(string $containerName, bool $cont const FVPLUS_MAX_FOLDER_STRING_BYTES = 2048; const FVPLUS_DOCKER_FOLDER_LABEL_KEYS = ['folderview.plus', 'folder.view3', 'folder.view2', 'folder.view']; const FVPLUS_DEFAULT_FOLDER_STATUS_COLORS = [ - 'started' => '#ffffff', + 'started' => '#55b72d', 'paused' => '#b8860b', - 'stopped' => '#ff4d4d' + 'stopped' => '#ff4d4d', + 'text' => '#ffffff' ]; const FVPLUS_LEGACY_CONFIG_DIRS = [ '/boot/config/plugins/folder.view3', @@ -444,6 +445,7 @@ function fv3_get_tailscale_fqdn_from_container(string $containerName, bool $cont require_once(__DIR__ . '/lib.api-contract.php'); require_once(__DIR__ . '/lib.preflight.php'); + require_once(__DIR__ . '/lib.webui-profiles.php'); require_once(__DIR__ . '/lib.prefs.php'); require_once(__DIR__ . '/lib.diagnostics.php'); require_once(__DIR__ . '/lib.docker-runtime.php'); @@ -1198,6 +1200,10 @@ function normalizeFolderContentPayload(array $content): array { if (!is_array($normalized['settings'] ?? null)) { $normalized['settings'] = []; } + $normalized['settings']['webui_profiles'] = fvplusNormalizeWebuiProfiles( + $normalized['settings']['webui_profiles'] ?? ($normalized['settings']['webuiProfiles'] ?? []) + ); + unset($normalized['settings']['webuiProfiles']); $rawPreviewRows = $normalized['settings']['preview_rows'] ?? ($normalized['settings']['previewRows'] ?? ($normalized['preview_rows'] @@ -1330,7 +1336,7 @@ function normalizeFolderSettingsTransferPayload(array $payload): array { $normalized['settings'] = []; if (array_key_exists('settings', $payload) && is_array($payload['settings'])) { $settingsCarrier = normalizeFolderContentPayload(['settings' => $payload['settings']]); - $normalized['settings'] = is_array($settingsCarrier['settings'] ?? null) ? $settingsCarrier['settings'] : []; + $normalized['settings'] = fvplusStripWebuiProfilesFromSettings($settingsCarrier['settings'] ?? []); } $normalizedActions = []; @@ -1800,8 +1806,15 @@ function reorderFolderMapByPrefs(string $type, array $folders): array { } function reconcileManualOrderPrefs(array $prefs, array $folders): array { + $nextPrefs = $prefs; + $nextPrefs['hiddenFolderIds'] = array_values(array_filter( + normalizeStringIdList($prefs['hiddenFolderIds'] ?? []), + static function ($id) use ($folders): bool { + return array_key_exists((string)$id, $folders); + } + )); if (($prefs['sortMode'] ?? 'created') !== 'manual') { - return $prefs; + return $nextPrefs; } $order = []; foreach ((array)($prefs['manualOrder'] ?? []) as $id) { @@ -1814,8 +1827,8 @@ function reconcileManualOrderPrefs(array $prefs, array $folders): array { $order[] = $id; } } - $prefs['manualOrder'] = $order; - return $prefs; + $nextPrefs['manualOrder'] = $order; + return $nextPrefs; } function syncManualOrderWithFolders(string $type, array $folders): void { @@ -1998,7 +2011,7 @@ function normalizeTemplateEntry(array $template): ?array { if ($id === '') { $id = generateId(12); } - $settings = is_array($template['settings'] ?? null) ? $template['settings'] : []; + $settings = fvplusStripWebuiProfilesFromSettings($template['settings'] ?? []); $actions = is_array($template['actions'] ?? null) ? array_values($template['actions']) : []; return [ 'id' => $id, @@ -2079,7 +2092,7 @@ function createFolderTemplateFromFolder(string $type, string $folderId, string $ 'name' => $name, 'icon' => (string)($folder['icon'] ?? ''), 'regex' => (string)($folder['regex'] ?? ''), - 'settings' => is_array($folder['settings'] ?? null) ? $folder['settings'] : [], + 'settings' => fvplusStripWebuiProfilesFromSettings($folder['settings'] ?? []), 'actions' => is_array($folder['actions'] ?? null) ? $folder['actions'] : [], 'createdAt' => gmdate('c') ]; @@ -2554,7 +2567,6 @@ function autoRuleDecision(array $rules, string $name, array $infoByName, string } require_once(__DIR__ . '/lib.folder-rules.php'); require_once(__DIR__ . '/lib.docker-start-order-sequence.php'); - require_once(__DIR__ . '/lib.docker-order.php'); require_once(__DIR__ . '/lib.folder-mutations.php'); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.prefs.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.prefs.php index c7a939fc..f40d84f0 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.prefs.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.prefs.php @@ -9,6 +9,7 @@ function defaultTypePrefs(): array { 'sortMode' => 'created', 'manualOrder' => [], 'pinnedFolderIds' => [], + 'hiddenFolderIds' => [], 'expandedFolderState' => [], 'hideEmptyFolders' => false, 'appColumnWidth' => 'standard', @@ -108,6 +109,7 @@ function normalizeTypeFolderDefaultsProfile($value): array { $incoming = is_array($value) ? $value : []; $profileIncoming = is_array($incoming['profile'] ?? null) ? $incoming['profile'] : []; $settingsIncoming = is_array($profileIncoming['settings'] ?? null) ? $profileIncoming['settings'] : []; + $settingsIncoming = fvplusStripWebuiProfilesFromSettings($settingsIncoming); $actionsIncoming = is_array($profileIncoming['actions'] ?? null) ? $profileIncoming['actions'] : []; $normalizedActions = []; @@ -442,6 +444,7 @@ function normalizeTypePrefs(array $prefs): array { } $normalized['manualOrder'] = normalizeStringIdList($manualOrder); $normalized['pinnedFolderIds'] = normalizeStringIdList($prefs['pinnedFolderIds'] ?? []); + $normalized['hiddenFolderIds'] = normalizeStringIdList($prefs['hiddenFolderIds'] ?? []); $normalized['expandedFolderState'] = normalizeExpandedStateMap($prefs['expandedFolderState'] ?? []); $normalized['hideEmptyFolders'] = normalizeBool($prefs['hideEmptyFolders'] ?? false, false); $normalized['appColumnWidth'] = normalizeAppColumnWidth($prefs['appColumnWidth'] ?? 'standard'); diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.validation.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.validation.php index 04a3f326..1b15fef5 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.validation.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.validation.php @@ -152,6 +152,9 @@ function fvplus_assert_prefs_payload_shape(array $payload): void { if (array_key_exists('pinnedFolderIds', $payload)) { fvplus_validation_assert_list_of_scalarish($payload['pinnedFolderIds'], 'pinnedFolderIds', 10000); } + if (array_key_exists('hiddenFolderIds', $payload)) { + fvplus_validation_assert_list_of_scalarish($payload['hiddenFolderIds'], 'hiddenFolderIds', 10000); + } if (array_key_exists('expandedFolderState', $payload)) { fvplus_validation_assert_assoc_map($payload['expandedFolderState'], 'expandedFolderState', 20000); } diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.webui-profiles.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.webui-profiles.php new file mode 100644 index 00000000..403a2c39 --- /dev/null +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.webui-profiles.php @@ -0,0 +1,127 @@ + $rawProfile) { + if (count($profiles) >= FVPLUS_WEBUI_PROFILE_MAX_PROFILES || !is_array($rawProfile)) { + continue; + } + $id = fvplusWebuiProfileCleanString($rawProfile['id'] ?? '', 64); + if (!preg_match('/^[A-Za-z0-9._:-]{1,64}$/', $id)) { + $id = 'profile-' . ((int)$sourceIndex + 1); + } + $baseId = $id; + $suffix = 2; + while (isset($usedIds[$id])) { + $id = substr($baseId, 0, 58) . '-' . $suffix; + $suffix++; + } + $usedIds[$id] = true; + + $name = fvplusWebuiProfileCleanString($rawProfile['name'] ?? '', FVPLUS_WEBUI_PROFILE_MAX_NAME_BYTES); + if ($name === '') { + $name = 'Profile ' . (count($profiles) + 1); + } + $baseName = $name; + $nameKey = strtolower($name); + $suffix = 2; + while (isset($usedNames[$nameKey])) { + $suffixText = ' ' . $suffix; + $name = substr($baseName, 0, max(1, FVPLUS_WEBUI_PROFILE_MAX_NAME_BYTES - strlen($suffixText))) . $suffixText; + $nameKey = strtolower($name); + $suffix++; + } + $usedNames[$nameKey] = true; + + $rawMembers = is_array($rawProfile['containers'] ?? null) + ? $rawProfile['containers'] + : (is_array($rawProfile['members'] ?? null) ? $rawProfile['members'] : []); + $containers = []; + $seenMembers = []; + foreach ($rawMembers as $rawMember) { + if (count($containers) >= FVPLUS_WEBUI_PROFILE_MAX_MEMBERS) { + break; + } + $member = fvplusWebuiProfileCleanString($rawMember, FVPLUS_WEBUI_PROFILE_MAX_MEMBER_BYTES); + if ($member === '' || isset($seenMembers[$member])) { + continue; + } + $seenMembers[$member] = true; + $containers[] = $member; + } + $profiles[] = ['id' => $id, 'name' => $name, 'containers' => $containers]; + } + return $profiles; +} + +function fvplusStripWebuiProfilesFromSettings($settings): array { + $normalized = is_array($settings) ? $settings : []; + unset($normalized['webui_profiles'], $normalized['webuiProfiles']); + return $normalized; +} + +function fvplusBuildWebuiProfileDiagnostics($settings, array $availableMembers): array { + $rawSettings = is_array($settings) ? $settings : []; + $rawProfiles = $rawSettings['webui_profiles'] ?? ($rawSettings['webuiProfiles'] ?? []); + $invalidProfileCount = 0; + $rawIds = []; + $duplicateProfileIdCount = 0; + foreach (is_array($rawProfiles) ? $rawProfiles : [] as $rawProfile) { + if (!is_array($rawProfile)) { + $invalidProfileCount++; + continue; + } + $rawId = fvplusWebuiProfileCleanString($rawProfile['id'] ?? '', 64); + $rawName = fvplusWebuiProfileCleanString($rawProfile['name'] ?? '', FVPLUS_WEBUI_PROFILE_MAX_NAME_BYTES); + if ($rawId === '' || !preg_match('/^[A-Za-z0-9._:-]{1,64}$/', $rawId) || $rawName === '') { + $invalidProfileCount++; + } + if ($rawId !== '' && isset($rawIds[$rawId])) { + $duplicateProfileIdCount++; + } + if ($rawId !== '') { + $rawIds[$rawId] = true; + } + } + $profiles = fvplusNormalizeWebuiProfiles($rawProfiles); + $available = array_fill_keys(array_values(array_unique(array_map('strval', $availableMembers))), true); + $selectedReferenceCount = 0; + $unavailableReferenceCount = 0; + foreach ($profiles as $profile) { + foreach ($profile['containers'] as $member) { + $selectedReferenceCount++; + if (!isset($available[$member])) { + $unavailableReferenceCount++; + } + } + } + return [ + 'profileCount' => count($profiles), + 'selectedReferenceCount' => $selectedReferenceCount, + 'unavailableReferenceCount' => $unavailableReferenceCount, + 'duplicateProfileIdCount' => $duplicateProfileIdCount, + 'invalidProfileCount' => $invalidProfileCount + ]; +} + +function fvplusMergeWebuiProfileDiagnostics(array $left, array $right): array { + $keys = ['profileCount', 'selectedReferenceCount', 'unavailableReferenceCount', 'duplicateProfileIdCount', 'invalidProfileCount']; + $merged = []; + foreach ($keys as $key) { + $merged[$key] = max(0, (int)($left[$key] ?? 0)) + max(0, (int)($right[$key] ?? 0)); + } + return $merged; +} diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/prefs.php b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/prefs.php index 973c995b..a49abc1a 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/prefs.php +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/prefs.php @@ -47,6 +47,7 @@ (string)($current['sortMode'] ?? 'created') !== (string)($saved['sortMode'] ?? 'created') || normalizeStringIdList($current['manualOrder'] ?? []) !== normalizeStringIdList($saved['manualOrder'] ?? []) || normalizeStringIdList($current['pinnedFolderIds'] ?? []) !== normalizeStringIdList($saved['pinnedFolderIds'] ?? []) + || normalizeStringIdList($current['hiddenFolderIds'] ?? []) !== normalizeStringIdList($saved['hiddenFolderIds'] ?? []) ); if ($orderPrefsChanged) { syncManualOrderWithFolders($type, readRawFolderMap($type)); @@ -75,7 +76,8 @@ 'backupCoalesced' => (bool)($backup['coalesced'] ?? false), 'sortMode' => (string)($saved['sortMode'] ?? 'created'), 'ruleCount' => count($saved['autoRules'] ?? []), - 'pinnedFolderCount' => count($saved['pinnedFolderIds'] ?? []) + 'pinnedFolderCount' => count($saved['pinnedFolderIds'] ?? []), + 'hiddenFolderCount' => count($saved['hiddenFolderIds'] ?? []) ], 'ok', 'server'); $auditRecorded = true; } catch (Throwable $err) { diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/docker.css b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/docker.css index 0d75a4cc..2b0b75b5 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/docker.css +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/docker.css @@ -550,12 +550,45 @@ tr.fv-folder-locked .folder-appname { display: none !important; } +#docker_list > tr.fv-folder-user-hidden { + display: none !important; +} + +body.fvplus-docker-reveal-hidden #docker_list > tr.fv-folder-user-hidden:not(.fv-toolbar-filter-hidden):not(.fv-folder-focus-hidden) { + display: table-row !important; + opacity: 0.58; +} + +body.fvplus-docker-reveal-hidden #docker_list > tr.folder.fv-folder-hidden-revealed > td { + background-image: linear-gradient(rgba(255, 154, 60, 0.045), rgba(255, 154, 60, 0.045)); +} + +.fv-folder-hidden-indicator { + display: inline-flex; + align-items: center; + gap: 0.24rem; + margin-left: 0.45rem; + color: #ff9a3c; + font-size: 0.86em; + font-weight: 700; + text-transform: uppercase; +} + +.fv-folder-hidden-indicator-icon, +.fvplus-docker-quick-svg, +.fvplus-docker-action-svg, +.fvplus-docker-visibility-notice-icon { + width: 1em; + height: 1em; + flex: 0 0 auto; +} + .folder-update-text { white-space: nowrap; } span.folder-state.fv-folder-state-started { - color: var(--fvplus-folder-status-started, var(--fvplus-status-started)); + color: var(--fvplus-folder-status-text, #ffffff); } span.folder-state { @@ -565,11 +598,11 @@ span.folder-state { } span.folder-state.fv-folder-state-paused { - color: var(--fvplus-folder-status-paused, var(--fvplus-status-paused)); + color: var(--fvplus-folder-status-text, #ffffff); } span.folder-state.fv-folder-state-stopped { - color: var(--fvplus-folder-status-stopped, var(--fvplus-status-stopped)); + color: var(--fvplus-folder-status-text, #ffffff); } i.folder-load-status.started { @@ -973,6 +1006,29 @@ tr.folder.fv-parent-expanded div.folder-preview { color: var(--fvplus-folder-status-stopped, var(--fvplus-status-stopped)) !important; } +.folder-preview .fv-preview-status-compact .state, +.folder-preview span.state[class*="fv-preview-status-"] { + color: var(--fvplus-folder-status-text, #ffffff) !important; +} + +.folder-preview i.fv-preview-lifecycle-pending { + color: var(--fvplus-folder-status-started, var(--fvplus-status-started)) !important; +} + +.folder-preview [data-fv-lifecycle-action="start"] i.fv-preview-lifecycle-pending, +.folder-preview [data-fv-lifecycle-action="resume"] i.fv-preview-lifecycle-pending, +.folder-preview [data-fv-lifecycle-action="restart"] i.fv-preview-lifecycle-pending { + color: var(--fvplus-folder-status-started, var(--fvplus-status-started)) !important; +} + +.folder-preview [data-fv-lifecycle-action="pause"] i.fv-preview-lifecycle-pending { + color: var(--fvplus-folder-status-paused, var(--fvplus-status-paused)) !important; +} + +.folder-preview [data-fv-lifecycle-action="stop"] i.fv-preview-lifecycle-pending { + color: var(--fvplus-folder-status-stopped, var(--fvplus-status-stopped)) !important; +} + .folder-preview-wrapper .inner > span.appname { display: inline-block; overflow: clip; @@ -1391,6 +1447,12 @@ div.folder-preview-4 span.outer { font-size: 14px !important; } +.fvplus-docker-context-menu > li.fvplus-docker-quick-item .fvplus-docker-quick-svg { + width: 15px; + height: 15px; + font-size: 15px !important; +} + .fvplus-docker-context-menu > li.fvplus-docker-quick-clear { clear: both; } @@ -1688,6 +1750,59 @@ body.fvplus-privacy-vm-sensitive-externalUrls a.fvplus-sensitive-external-url { background: rgba(128, 128, 128, 0.32); } +.fvplus-docker-action-menu-item .fvplus-docker-action-svg { + justify-self: center; +} + +.fvplus-docker-visibility-notice { + flex: 1 0 100%; + width: 100%; + min-width: 0; + box-sizing: border-box; + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.58rem 0.72rem; + border: 1px solid rgba(255, 154, 60, 0.48); + border-radius: 6px; + background: rgba(255, 154, 60, 0.08); + color: inherit; +} + +.fvplus-docker-visibility-notice > span { + flex: 1 1 auto; + min-width: 0; +} + +.fvplus-docker-visibility-notice > button { + flex: 0 0 auto; + margin: 0 !important; +} + +.fvplus-docker-visibility-notice > .fvplus-docker-visibility-button { + border: 1px solid var(--fvplus-ui-border-subtle, rgba(128, 128, 128, 0.34)) !important; + background: var(--fvplus-ui-control, rgba(128, 128, 128, 0.1)) !important; + color: var(--fvplus-ui-text-primary, inherit) !important; + box-shadow: none !important; +} + +.fvplus-docker-visibility-notice > .fvplus-docker-visibility-button:hover:not(:disabled) { + border-color: var(--fvplus-ui-border-strong, rgba(128, 128, 128, 0.58)) !important; + background: var(--fvplus-ui-control-hover, rgba(128, 128, 128, 0.2)) !important; + color: var(--fvplus-ui-text-primary, inherit) !important; +} + +.fvplus-docker-visibility-notice > .fvplus-docker-visibility-button:focus-visible { + border-color: var(--fvplus-ui-border-strong, rgba(128, 128, 128, 0.58)) !important; + box-shadow: 0 0 0 3px var(--fvplus-ui-focus-ring, rgba(74, 179, 255, 0.24)) !important; + outline: 0 !important; +} + +.fvplus-docker-visibility-notice.is-undo { + border-color: rgba(85, 183, 45, 0.52); + background: rgba(85, 183, 45, 0.08); +} + #docker_list > tr.fv-toolbar-filter-hidden { display: none !important; } @@ -1727,4 +1842,13 @@ body.fvplus-privacy-vm-sensitive-externalUrls a.fvplus-sensitive-external-url { .fvplus-docker-action-separator { display: none; } + + .fvplus-docker-visibility-notice { + align-items: stretch; + flex-wrap: wrap; + } + + .fvplus-docker-visibility-notice > span { + flex-basis: calc(100% - 2rem); + } } diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/folder.css b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/folder.css index 06c5116a..b882c164 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/folder.css +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/folder.css @@ -4954,7 +4954,8 @@ table.sortable td:nth-child(4) { .fv-third-party-pack-actions-panel > button, .fv-third-party-inline-actions > button, .fv-modern-field-row .custom-action, - .fv-folder-action-dialog .ui-dialog-buttonpane button + .fv-folder-action-dialog .ui-dialog-buttonpane button, + .fv-webui-profile-button ) { border: 0 !important; border-radius: 8px !important; @@ -4982,7 +4983,8 @@ table.sortable td:nth-child(4) { .fv-third-party-pack-actions-panel > button, .fv-third-party-inline-actions > button, .fv-modern-field-row .custom-action, - .fv-folder-action-dialog .ui-dialog-buttonpane button + .fv-folder-action-dialog .ui-dialog-buttonpane button, + .fv-webui-profile-button ):hover, :is( .fv-editor-hero-actions > button, @@ -5001,7 +5003,8 @@ table.sortable td:nth-child(4) { .fv-third-party-pack-actions-panel > button, .fv-third-party-inline-actions > button, .fv-modern-field-row .custom-action, - .fv-folder-action-dialog .ui-dialog-buttonpane button + .fv-folder-action-dialog .ui-dialog-buttonpane button, + .fv-webui-profile-button ):focus-visible { background: var(--fv-editor-button-hover-top) !important; box-shadow: var(--fv-editor-button-shadow-hover) !important; @@ -5024,7 +5027,8 @@ table.sortable td:nth-child(4) { .fv-third-party-pack-actions-panel > button, .fv-third-party-inline-actions > button, .fv-modern-field-row .custom-action, - .fv-folder-action-dialog .ui-dialog-buttonpane button + .fv-folder-action-dialog .ui-dialog-buttonpane button, + .fv-webui-profile-button ):active { background: var(--fv-editor-button-active-top) !important; box-shadow: var(--fv-editor-button-shadow-active) !important; @@ -5295,6 +5299,130 @@ body[data-fvplus-host-theme="white"] #fvEditorActionBar .folder-btn-submit:activ display: none !important; } +.fv-webui-profiles-section dl, +.fv-webui-profiles-section dd { + width: 100%; +} + +#fvWebuiProfilesWorkspace { + display: grid; + gap: 0.85rem; + width: 100%; +} + +.fv-webui-profiles-toolbar, +.fv-webui-profile-head, +.fv-webui-profile-tools, +.fv-webui-profile-member { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.fv-webui-profiles-toolbar, +.fv-webui-profile-head { + justify-content: space-between; +} + +.fv-webui-profiles-toolbar p, +.fv-webui-profile-card p { + margin: 0; +} + +.fv-webui-profile-list { + display: grid; + gap: 0.85rem; +} + +.fv-webui-profile-card { + display: grid; + gap: 0.75rem; + padding: 0.9rem; + border: 1px solid var(--fv-editor-border, rgba(127, 127, 127, 0.35)); + border-radius: 8px; + background: var(--fv-editor-panel, rgba(127, 127, 127, 0.06)); +} + +.fv-webui-profile-name, +.fv-webui-profile-tools input[type="search"] { + min-width: 0; + border-color: var(--fv-editor-border, currentColor); + background: var(--fv-editor-input-bg, inherit); + color: inherit; +} + +.fv-webui-profile-name { + flex: 1 1 18rem; + font-weight: 700; +} + +.fv-webui-profile-actions, +.fv-webui-profile-tools { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.fv-webui-profile-tools input[type="search"] { + flex: 1 1 14rem; +} + +.fv-webui-profile-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + min-height: 34px; + padding: 0.35rem 0.65rem; + cursor: pointer; +} + +.fv-webui-profile-button:focus-visible { + outline: 2px solid var(--fv-editor-focus-ring) !important; + outline-offset: 2px; +} + +.fv-webui-profile-button > i, +.fv-webui-profile-button > span { + color: inherit !important; +} + +.fv-webui-profile-members { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 0.45rem; +} + +.fv-webui-profile-member { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + padding: 0.55rem 0.65rem; + border: 1px solid var(--fv-editor-border, rgba(127, 127, 127, 0.3)); + border-radius: 6px; + cursor: pointer; +} + +.fv-webui-profile-member > span { + grid-column: 2; + opacity: 0.72; + font-size: 0.86rem; +} + +.fv-webui-profile-member.is-unavailable { + border-style: dashed; + opacity: 0.75; +} + +.fv-webui-profiles-status { + min-height: 1.3em; +} + +.fv-webui-profiles-status.is-error, +.fv-input-error { + color: var(--fv-editor-danger, #e05252); + border-color: var(--fv-editor-danger, #e05252) !important; +} + @media (max-width: 980px) { #fvEditorChrome { position: static; @@ -5378,6 +5506,23 @@ body[data-fvplus-host-theme="white"] #fvEditorActionBar .folder-btn-submit:activ width: 100%; } + .fv-webui-profiles-toolbar, + .fv-webui-profile-head, + .fv-webui-profile-tools { + align-items: stretch; + flex-direction: column; + } + + .fv-webui-profile-actions, + .fv-webui-profile-button, + .fv-webui-profile-tools input[type="search"] { + width: 100%; + } + + .fv-webui-profile-actions .fv-webui-profile-button { + flex: 1 1 9rem; + } + .fv-custom-icon-row { grid-template-columns: 24px minmax(0, 1fr); align-items: start; diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/folderviewplus.css b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/folderviewplus.css index bd1014b7..3aa66a9d 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/folderviewplus.css +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/folderviewplus.css @@ -3544,7 +3544,7 @@ body.fv-mobile-compact #fv-settings-root .status-breakdown-list { --fvplus-theme-accent: var(--fvplus-settings-accent, #f0a030); --fvplus-theme-surface-panel: var(--fvplus-settings-surface-panel, #1b1d20); --fvplus-theme-border-subtle: var(--fvplus-settings-border-subtle, rgba(255, 255, 255, 0.12)); - --fvplus-status-started: #ffffff; + --fvplus-status-started: #55b72d; --fvplus-status-paused: #b8860b; --fvplus-status-stopped: #ff4d4d; --fvplus-graph-cpu: #5aa4ff; diff --git a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/vm.css b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/vm.css index 8b82031f..f46b13eb 100644 --- a/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/vm.css +++ b/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/vm.css @@ -241,7 +241,7 @@ tr.fv-folder-locked .folder-appname { } span.folder-state.fv-folder-state-started { - color: var(--fvplus-folder-status-started, var(--fvplus-status-started)); + color: var(--fvplus-folder-status-text, #ffffff); } span.folder-state { @@ -251,11 +251,11 @@ span.folder-state { } span.folder-state.fv-folder-state-paused { - color: var(--fvplus-folder-status-paused, var(--fvplus-status-paused)); + color: var(--fvplus-folder-status-text, #ffffff); } span.folder-state.fv-folder-state-stopped { - color: var(--fvplus-folder-status-stopped, var(--fvplus-status-stopped)); + color: var(--fvplus-folder-status-text, #ffffff); } i.folder-load-status.started { diff --git a/tests/architecture-boundaries.test.mjs b/tests/architecture-boundaries.test.mjs index aeff5787..f86b102c 100644 --- a/tests/architecture-boundaries.test.mjs +++ b/tests/architecture-boundaries.test.mjs @@ -15,14 +15,14 @@ test('all FolderView Plus page and loader script graphs are contract-guarded', ( assert.deepEqual(result.failures, []); assert.equal(result.pageCount, 5); assert.equal(result.sourceCount, 8); - assert.equal(result.referenceCount, 290); + assert.equal(result.referenceCount, 296); }); test('entrypoints and contracted modules declare ownership boundaries', () => { assert.equal(architecture.schemaVersion, 2); assert.equal(architecture.entrypointContracts.length, 9); - assert.equal(architecture.moduleContracts.length, 50); - assert.equal(architecture.serverModuleContracts.length, 29); + assert.equal(architecture.moduleContracts.length, 53); + assert.equal(architecture.serverModuleContracts.length, 30); const allowedConsumers = new Set(architecture.consumerScopes); for (const contract of [...architecture.entrypointContracts, ...architecture.moduleContracts, ...architecture.serverModuleContracts]) { assert.ok(contract.file, 'boundary contract must name its file'); diff --git a/tests/browser/cases/docker-hidden-folders.mjs b/tests/browser/cases/docker-hidden-folders.mjs new file mode 100644 index 00000000..b30e8ea7 --- /dev/null +++ b/tests/browser/cases/docker-hidden-folders.mjs @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import { assertImmediateThemedUndo } from '../helpers/docker-hidden-folder-context.mjs'; + +export const registerDockerHiddenFolderFixtureCases = ({ test, baseUrl }) => { +test('Docker hidden folders remain recoverable through reveal, restore all, and undo', async ({ page }) => { + await page.goto(`${baseUrl}/runtime`, { waitUntil: 'load' }); + await assertImmediateThemedUndo(page, 'media'); + + await page.click('[data-fvplus-docker-menu="view"]'); + const revealControl = page.locator('#fvplus-docker-action-bar .fvplus-docker-action-menu.is-open [data-fvplus-docker-hidden="toggle-reveal"]'); + assert.match(await revealControl.textContent(), /Hidden folders \(1\)/); + await revealControl.click(); + assert.equal(await page.locator('#docker_list > tr.folder-id-media').isVisible(), true); + assert.equal(await page.locator('#docker_list > tr.folder-id-media .fv-folder-hidden-indicator').count(), 1); + + await page.click('[data-fvplus-docker-menu="view"]'); + await page.click('[data-fvplus-docker-hidden="restore-all"]'); + await page.waitForFunction(() => window.fixtureRuntime.hiddenFolders.getSummary().explicitCount === 0); + assert.equal(await page.locator('#docker_list > tr.fv-folder-user-hidden').count(), 0); + + await page.evaluate(() => window.fixtureRuntime.hiddenFolders.hideFolder('updates')); + await page.click('[data-fvplus-docker-hidden="undo"]'); + await page.waitForFunction(() => window.fixtureRuntime.hiddenFolders.getSummary().explicitCount === 0); + assert.equal(await page.locator('#docker_list > tr.folder-id-updates').isVisible(), true); +}); +}; diff --git a/tests/browser/cases/docker.mjs b/tests/browser/cases/docker.mjs index 921808bb..1cc7ce40 100644 --- a/tests/browser/cases/docker.mjs +++ b/tests/browser/cases/docker.mjs @@ -1,5 +1,8 @@ import assert from 'node:assert/strict'; -import { exerciseDockerPreviewContextDiagnostics } from '../helpers/docker-preview-context.mjs'; +import { + exerciseChildFolderPreviewContext, + exerciseDockerPreviewContextDiagnostics +} from '../helpers/docker-preview-context.mjs'; export const registerDockerFixtureCases = ({ test, baseUrl }) => { test('Docker action bar is idempotent and reports fixture counts', async ({ page }) => { @@ -61,13 +64,29 @@ test('Docker single-row preview cloning falls back without stopping later member assert.deepEqual(result.missingWrapper, { ok: false, reason: 'preview-markup-missing' }); assert.deepEqual(result.invalidSelector, { ok: false, reason: 'preview-clone-failed' }); assert.equal(result.renderedCount, 3, 'a malformed member must not stop subsequent preview rendering'); + assert.equal(result.renderedContainerId, 'fixture-0'); + assert.equal(result.renderedContainerName, 'fixture-container-0'); assert.equal(result.fallbackCount, 2); }); +test('Docker lifecycle pending colors follow each action destination state', async ({ page }) => { + await page.goto(`${baseUrl}/docker-layout-stability`, { waitUntil: 'load' }); + const colors = await page.evaluate(() => window.fixtureDockerLifecyclePendingColors.run()); + assert.equal(colors.start, 'rgb(17, 170, 34)'); + assert.equal(colors.resume, 'rgb(17, 170, 34)'); + assert.equal(colors.restart, 'rgb(17, 170, 34)'); + assert.equal(colors.pause, 'rgb(204, 153, 0)'); + assert.equal(colors.stop, 'rgb(221, 34, 51)'); +}); + test('Docker multi-row previews bridge native context without cloned handlers or duplicate ids', async ({ page }) => { await exerciseDockerPreviewContextDiagnostics({ page, baseUrl }); }); +test('Docker child-folder preview chip opens its action menu after multi-row reflow', async ({ page }) => { + await exerciseChildFolderPreviewContext({ page, baseUrl }); +}); + test('Docker folder context menu opens from the first folder-icon click', async ({ page }) => { await page.goto(`${baseUrl}/docker-layout-stability`, { waitUntil: 'load' }); const result = await page.evaluate(() => window.fixtureFolderContextFirstClick.run()); diff --git a/tests/browser/cases/folder-editor.mjs b/tests/browser/cases/folder-editor.mjs index 3d3e418c..08ce281f 100644 --- a/tests/browser/cases/folder-editor.mjs +++ b/tests/browser/cases/folder-editor.mjs @@ -1,5 +1,4 @@ import assert from 'node:assert/strict'; - export const registerFolderEditorFixtureCases = ({ test, baseUrl }) => { test('Folder action sheet uses the compact retained action set and accessible dialog', async ({ page }) => { await page.goto(`${baseUrl}/folder-editor`, { waitUntil: 'load' }); @@ -41,4 +40,5 @@ test('Folder action sheet traps focus, copies details, and restores trigger focu assert.equal(await page.evaluate(() => window.fixtureFolderEditor.calls.includes('delete')), true); assert.equal(await page.locator('#fv-folder-action-sheet-backdrop').count(), 0); }); + }; diff --git a/tests/browser/cases/folder-webui-profile-theme.mjs b/tests/browser/cases/folder-webui-profile-theme.mjs new file mode 100644 index 00000000..cd507a66 --- /dev/null +++ b/tests/browser/cases/folder-webui-profile-theme.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +export const registerFolderWebuiProfileThemeFixtureCase = ({ test, baseUrl }) => { +test('Custom WebUI profile buttons resist Unraid defaults and follow editor theme tokens', async ({ page }) => { + await page.goto(`${baseUrl}/folder-editor`, { waitUntil: 'load' }); + await page.getByRole('button', { name: 'Add profile' }).click(); + const readTheme = () => page.evaluate(() => { + const form = document.querySelector('.folder-editor-form'); + const neutral = document.querySelector('.fv-webui-profile-actions .fv-webui-profile-button:not(.is-danger)'); + const neutralProbe = document.createElement('span'); + neutralProbe.style.cssText = 'position:absolute;left:-9999px;background:var(--fv-editor-button-bg-top);color:var(--fv-editor-button-fg)'; + form.append(neutralProbe); + const neutralStyle = getComputedStyle(neutral); + const neutralProbeStyle = getComputedStyle(neutralProbe); + const result = { + background: neutralStyle.backgroundColor, expectedBackground: neutralProbeStyle.backgroundColor, + color: neutralStyle.color, expectedColor: neutralProbeStyle.color, + borderWidth: neutralStyle.borderTopWidth + }; + neutralProbe.remove(); + return result; + }); + const dark = await readTheme(); + assert.equal(dark.background, dark.expectedBackground); + assert.equal(dark.color, dark.expectedColor); + assert.equal(dark.borderWidth, '0px'); + assert.notEqual(dark.color, 'rgb(255, 128, 0)'); + await page.evaluate(() => { document.documentElement.dataset.fvplusHostTheme = 'white'; }); + await page.waitForTimeout(200); + const light = await readTheme(); + assert.equal(light.background, light.expectedBackground); + assert.equal(light.color, light.expectedColor); + assert.equal(light.borderWidth, '0px'); + assert.notEqual(light.background, dark.background); + await page.evaluate(() => { delete document.documentElement.dataset.fvplusHostTheme; }); + await page.waitForTimeout(200); +}); +}; diff --git a/tests/browser/cases/folder-webui-profiles.mjs b/tests/browser/cases/folder-webui-profiles.mjs new file mode 100644 index 00000000..0d272bb9 --- /dev/null +++ b/tests/browser/cases/folder-webui-profiles.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; + +export const registerFolderWebuiProfileFixtureCases = ({ test, baseUrl }) => { +test('Custom WebUI profiles support create, select, duplicate, reorder, and delete', async ({ page }) => { + await page.goto(`${baseUrl}/folder-editor`, { waitUntil: 'load' }); + await page.getByRole('button', { name: 'Add profile' }).click(); + const card = page.locator('.fv-webui-profile-card').first(); + await card.locator('.fv-webui-profile-name').fill('Media tools'); + await card.getByText('Plex', { exact: true }).click(); + await card.getByText('Tautulli', { exact: true }).click(); + let stored = JSON.parse(await page.locator('input[name="webui_profiles"]').inputValue()); + assert.deepEqual(stored[0].containers.sort(), ['Plex', 'Tautulli']); + await card.getByRole('button', { name: 'Duplicate' }).click(); + assert.equal(await page.locator('.fv-webui-profile-card').count(), 2); + await page.locator('.fv-webui-profile-card').nth(1).getByRole('button', { name: 'Move up' }).click(); + await page.locator('.fv-webui-profile-card').first().getByRole('button', { name: 'Delete' }).click(); + assert.equal(await page.locator('.fv-webui-profile-card').count(), 1); + stored = JSON.parse(await page.locator('input[name="webui_profiles"]').inputValue()); + assert.equal(stored[0].name, 'Media tools'); +}); + +test('Custom WebUI profile editor remains usable on mobile and keyboard focus is visible', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`${baseUrl}/folder-editor`, { waitUntil: 'load' }); + await page.getByRole('button', { name: 'Add profile' }).click(); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); + assert.ok(overflow <= 1, `unexpected horizontal overflow: ${overflow}px`); + await page.keyboard.press('Tab'); + const focusedTag = await page.evaluate(() => document.activeElement?.tagName || ''); + assert.ok(['BUTTON', 'INPUT'].includes(focusedTag)); +}); +}; diff --git a/tests/browser/cases/foundation.mjs b/tests/browser/cases/foundation.mjs index be030b91..648a5bf0 100644 --- a/tests/browser/cases/foundation.mjs +++ b/tests/browser/cases/foundation.mjs @@ -121,7 +121,7 @@ test('Generated localization covers initial, attributed, parameterized, and dyna await page.waitForTimeout(150); const settledSnapshot = await page.evaluate(() => window.FolderViewPlusI18n.snapshot()); assert.equal(snapshot.dynamicTranslationObserver, true); - assert.equal(snapshot.autoBoundMessageCount, 1574); + assert.equal(snapshot.autoBoundMessageCount, 1579); assert.ok(snapshot.autoTranslatedNodeCount >= 303); assert.equal(settledSnapshot.autoTranslatedNodeCount, snapshot.autoTranslatedNodeCount, 'localization must settle without observing its own writes forever'); }); diff --git a/tests/browser/fixtures/docker-layout-stability.html b/tests/browser/fixtures/docker-layout-stability.html index e76819ef..e0a6ef29 100644 --- a/tests/browser/fixtures/docker-layout-stability.html +++ b/tests/browser/fixtures/docker-layout-stability.html @@ -94,10 +94,23 @@
    + + + + + + + +
    + +
    +
    Child folder row
    + + @@ -162,6 +175,7 @@ previewMode: 1, context: 1, ctid: `fixture-${index}`, + containerName: `fixture-container-${index}`, appendModelPreview: () => { fallbackCount += 1; const $fallback = window.jQuery('Fallback'); @@ -180,10 +194,34 @@ missingWrapper: { ok: missingWrapper.ok, reason: missingWrapper.reason }, invalidSelector: { ok: invalidSelector.ok, reason: invalidSelector.reason }, renderedCount: renderTarget.children().length, + renderedContainerId: renderTarget.children().first().attr('data-fv-container-id'), + renderedContainerName: renderTarget.children().first().attr('data-fv-container-name'), fallbackCount }; } }; + window.fixtureDockerLifecyclePendingColors = { + run: () => { + const $preview = window.jQuery('
    ') + .css('--fvplus-folder-status-started', '#11aa22') + .css('--fvplus-folder-status-paused', '#cc9900') + .css('--fvplus-folder-status-stopped', '#dd2233'); + const colors = {}; + ['start', 'resume', 'restart', 'pause', 'stop'].forEach((action) => { + const $surface = window.jQuery('').attr('data-fv-lifecycle-action', action); + const $icon = window.jQuery(''); + $surface.append($icon); + $preview.append($surface); + }); + window.jQuery(document.body).append($preview); + $preview.children().each((_, surface) => { + const action = surface.getAttribute('data-fv-lifecycle-action'); + colors[action] = window.getComputedStyle(surface.querySelector('i')).color; + }); + $preview.remove(); + return colors; + } + }; window.localStorage.removeItem(window.FolderViewPlusDockerPreviewActions.PREVIEW_CONTEXT_DIAGNOSTICS_STORAGE_KEY); const actionsApi = window.FolderViewPlusDockerPreviewActions.createApi({ window, @@ -277,6 +315,141 @@ }; buildNativeContextPreview('rows-2'); buildNativeContextPreview('unlimited'); + const childFolderFixtureState = { + editCount: 0, + actionCount: 0, + contextBindings: { default: 0, advanced: 0 }, + contextClicks: { default: 0, advanced: 0 }, + contextAudits: 0 + }; + const childFolderFixtureFolders = { + 'fixture-parent': { + name: 'Private parent fixture', + settings: { preview: 1, preview_rows: 2, context: 1 }, + runtimeContainers: {}, + status: { expanded: false } + }, + 'fixture-child': { + name: 'Private child fixture', + icon: '', + settings: { preview: 1, preview_rows: 2 }, + runtimeContainers: {}, + status: { expanded: false } + } + }; + const childFolderFixtureHierarchy = { + ids: ['fixture-parent', 'fixture-child'], + parentById: { 'fixture-parent': '', 'fixture-child': 'fixture-parent' }, + childrenById: { 'fixture-parent': ['fixture-child'], 'fixture-child': [] } + }; + const layoutChildFolderFixtureRows = ($preview, settings) => { + const runtimeShared = window.FolderViewDockerRuntimeShared; + const wrappers = runtimeShared.flattenPreviewWrappers($preview); + runtimeShared.finalizePreviewRows($preview, wrappers.map((wrapper) => [wrapper]), settings); + }; + const childFolderHierarchyApi = window.FolderViewPlusDockerRuntimeHierarchy.createApi({ + window, + $: window.jQuery, + getGlobalFolders: () => childFolderFixtureFolders, + getDockerFolderHierarchy: () => childFolderFixtureHierarchy, + buildRuntimeContainerMapForFolder: (folderId, includeDescendants) => { + if (folderId === 'fixture-child') { + return { childMember: { name: 'Private child member', state: true } }; + } + if (folderId === 'fixture-parent' && includeDescendants !== true) { + return { + parentMemberOne: { name: 'Private parent member one', shortId: 'fixture-parent-one', state: true }, + parentMemberTwo: { name: 'Private parent member two', shortId: 'fixture-parent-two', state: true } + }; + } + return {}; + }, + buildDockerPreviewItem: ({ entry }) => { + const $item = window.jQuery('') + .attr('data-fv-container-id', entry?.shortId || '') + .find('.appname').text(entry?.name || 'Member').end(); + return { $item, $tooltipTrigger: $item.find('.fv-preview-trigger').first() }; + }, + bindDockerNestedPreviewContext: ($item, _tooltipTrigger, _entry, folder) => { + const mode = Number(folder?.settings?.context || 1) === 2 ? 'advanced' : 'default'; + childFolderFixtureState.contextBindings[mode] += 1; + $item + .attr('data-fixture-parent-context', mode) + .off('click.fixtureParentContext') + .on('click.fixtureParentContext', (event) => { + event.preventDefault(); + event.stopPropagation(); + childFolderFixtureState.contextClicks[mode] += 1; + }); + return true; + }, + auditDockerPreviewContextBridges: () => { + childFolderFixtureState.contextAudits += 1; + }, + applyFolderPreviewLayout: () => {}, + layoutFolderPreviewRows: layoutChildFolderFixtureRows, + editFolder: () => { childFolderFixtureState.editCount += 1; }, + openFolderActions: () => { childFolderFixtureState.actionCount += 1; }, + recordChildFolderPreviewRender: actionsApi.recordChildFolderPreviewRender, + recordChildFolderPreviewBinding: actionsApi.recordChildFolderPreviewBinding, + recordChildFolderPreviewMenuOpen: actionsApi.recordChildFolderPreviewMenuOpen + }); + const renderChildFolderFixture = () => childFolderHierarchyApi.renderNestedAggregatePreview( + 'fixture-parent', + childFolderFixtureFolders['fixture-parent'], + {}, + { includeChildFolders: true } + ); + renderChildFolderFixture(); + for (let pass = 0; pass < 2; pass += 1) { + layoutChildFolderFixtureRows( + window.jQuery('#fixture-child-folder-preview'), + childFolderFixtureFolders['fixture-parent'].settings + ); + } + window.fixtureChildFolderPreviewContext = { + closeMenu() { + window.jQuery('.fv-folder-preview-context-menu').remove(); + window.jQuery(document).off('click.fvFolderPreviewContext keydown.fvFolderPreviewContext'); + }, + dispatchTouchClick() { + this.closeMenu(); + const item = document.querySelector('#fixture-child-folder-preview .fv-folder-preview-child'); + const event = new Event('click', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'changedTouches', { + value: [{ clientX: 321, clientY: 145 }] + }); + item.dispatchEvent(event); + }, + rerenderMemberContext(contextMode, listViewMode = 'basic') { + childFolderFixtureFolders['fixture-parent'].settings.context = Number(contextMode); + document.cookie = `docker_listview_mode=${listViewMode}; path=/`; + renderChildFolderFixture(); + }, + snapshot() { + const menu = document.querySelector('.fv-folder-preview-context-menu'); + return { + chipCount: document.querySelectorAll('#fixture-child-folder-preview .fv-folder-preview-child').length, + rowCount: document.querySelectorAll('#fixture-child-folder-preview .folder-preview-row').length, + menuCount: document.querySelectorAll('.fv-folder-preview-context-menu').length, + menuLeft: Number.parseFloat(menu?.style?.left || '0'), + menuTop: Number.parseFloat(menu?.style?.top || '0'), + menuWidth: Number(menu?.offsetWidth || 180), + menuHeight: Number(menu?.offsetHeight || 112), + viewportWidth: Number(window.innerWidth || document.documentElement.clientWidth || 0), + viewportHeight: Number(window.innerHeight || document.documentElement.clientHeight || 0), + menuActions: Array.from(menu?.querySelectorAll('button') || []).map((button) => button.textContent.trim()), + focusedAction: document.activeElement?.textContent?.trim() || '', + editCount: childFolderFixtureState.editCount, + actionCount: childFolderFixtureState.actionCount, + memberContextBindings: { ...childFolderFixtureState.contextBindings }, + memberContextClicks: { ...childFolderFixtureState.contextClicks }, + memberContextAudits: childFolderFixtureState.contextAudits, + listViewMode: document.cookie.includes('docker_listview_mode=advanced') ? 'advanced' : 'basic', + diagnostics: actionsApi.getPreviewContextDiagnosticsSnapshot() + }; + } + }; window.fixtureNativePreviewContext.breakRows2HandlersAndAudit = () => { const $preview = window.jQuery('#fixture-context-preview-rows-2'); const rows = $preview.children('.folder-preview-row').get(); @@ -298,7 +471,7 @@ previewInlineHandlerCount: document.querySelectorAll('.fixture-context-preview [onclick], .fixture-context-preview [oncontextmenu]').length, startedClassCount: document.querySelectorAll('.fixture-context-preview .state.fv-preview-status-started').length, bridgeCount: document.querySelectorAll('.fixture-context-preview [data-fv-preview-context="native"][role="button"][tabindex="0"]').length, - previewRowCount: document.querySelectorAll('.fixture-context-preview .folder-preview-row').length, + previewRowCount: document.querySelectorAll('#fixture-context-preview-rows-2 .folder-preview-row, #fixture-context-preview-unlimited .folder-preview-row').length, diagnostics: actionsApi.getPreviewContextDiagnosticsSnapshot() }); const layoutEngine = window.FolderViewPlusRuntimeColumnLayout.createColumnLayoutEngine({ diff --git a/tests/browser/fixtures/folder-editor.html b/tests/browser/fixtures/folder-editor.html index c01bcf37..f78a9ead 100644 --- a/tests/browser/fixtures/folder-editor.html +++ b/tests/browser/fixtures/folder-editor.html @@ -6,17 +6,44 @@ FolderView Plus folder action fixture - + + - + +
    +
    + +
    +
    + + + +
    + diff --git a/tests/browser/fixtures/runtime.fixture.js b/tests/browser/fixtures/runtime.fixture.js index d3f0ec77..80b9adb5 100644 --- a/tests/browser/fixtures/runtime.fixture.js +++ b/tests/browser/fixtures/runtime.fixture.js @@ -12,6 +12,8 @@ let prefs = { pageViewMode: 'folderview', hideEmptyFolders: false, health: { warnStoppedPercent: 60 } }; const events = []; let refreshCount = 0; + let deferHiddenFolderSave = false; + let pendingHiddenFolderSave = null; const hostAdapter = window.FolderViewPlusRuntimeHostAdapters.getOrCreate('docker', { window, document }); const privacyEvents = []; let privacySwitchInitializeCount = 0; @@ -128,6 +130,7 @@ const normalizePrefs = (value = {}) => ({ pageViewMode: ['host', 'command'].includes(String(value.pageViewMode || '')) ? value.pageViewMode : 'folderview', hideEmptyFolders: value.hideEmptyFolders === true, + hiddenFolderIds: Array.isArray(value.hiddenFolderIds) ? [...new Set(value.hiddenFolderIds.map(String).filter(Boolean))] : [], health: { warnStoppedPercent: Number(value.health?.warnStoppedPercent) || 60 } }); const applyPrefs = (nextPrefs = prefs) => { @@ -140,7 +143,45 @@ row.hidden = mode === 'command'; }); }; - const api = window.FolderViewPlusDockerRuntimeActionBar.createApi({ + let api = null; + const hiddenFolders = window.FolderViewPlusFoundationModules.dockerHiddenFolders.createApi({ + window, + document, + $: window.jQuery, + getFolders: () => folders, + getPrefs: () => prefs, + setPrefs: (value) => { prefs = normalizePrefs(value); }, + normalizePrefs, + normalizeParentId: (value) => String(value || ''), + readFolderIdFromRow: (row) => String(row?.dataset?.folderId || ''), + readFolderOwnerFromRow: (row) => String(row?.dataset?.folderOwner || ''), + runtimeStateStore: { set: () => {} }, + safeActionRunner: window.FolderViewDockerRuntimeShared.createSafeUiActionRunner(), + runGuardedAction: async (_name, action) => { + try { return { ok: true, value: await action() }; } catch (error) { return { ok: false, error }; } + }, + savePrefs: async (patch) => { + const nextPrefs = normalizePrefs({ ...prefs, ...patch }); + if (deferHiddenFolderSave) { + return new Promise((resolve, reject) => { + pendingHiddenFolderSave = { nextPrefs, resolve, reject }; + }); + } + prefs = nextPrefs; + return prefs; + }, + fetchPrefs: async () => prefs, + syncDependentUi: () => api?.sync(), + getFocusedFolderId: () => '', + clearFocusedFolder: () => events.push({ type: 'clear-focus' }), + offerUndo: (payload) => api?.offerHiddenFolderUndo(payload), + dismissUndo: (folderId = '') => api?.clearHiddenFolderUndo(folderId), + translate: (_key, fallback) => fallback, + escapeHtml: (value) => String(value ?? '').replace(/[&<>"']/g, (char) => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[char])) + }); + api = window.FolderViewPlusDockerRuntimeActionBar.createApi({ window, document, hostAdapter, @@ -168,6 +209,7 @@ getFocusedFolderId: () => '', clearFocusedFolder: () => events.push({ type: 'clear-focus' }), scheduleWidthReflow: () => {}, + hiddenFolders, buildFolderHierarchy: () => ({ parentById: { media: '', updates: '', empty: '' } }), expandFolderBranch: (id) => events.push({ type: 'expand', id }), collapseFolderBranch: (id) => events.push({ type: 'collapse', id }), @@ -179,6 +221,28 @@ window.fixtureRuntime = { api, + hiddenFolders, + hiddenFolderPersistence: { + defer: () => { deferHiddenFolderSave = true; }, + isPending: () => !!pendingHiddenFolderSave, + resolve: () => { + const pending = pendingHiddenFolderSave; + pendingHiddenFolderSave = null; + deferHiddenFolderSave = false; + if (!pending) return false; + prefs = pending.nextPrefs; + pending.resolve(prefs); + return true; + }, + reject: () => { + const pending = pendingHiddenFolderSave; + pendingHiddenFolderSave = null; + deferHiddenFolderSave = false; + if (!pending) return false; + pending.reject(new Error('fixture hidden-folder save failed')); + return true; + } + }, events, privacyToggle: { rememberIdentity: () => { diff --git a/tests/browser/fixtures/runtime.html b/tests/browser/fixtures/runtime.html index 4a71aef1..f0bcff68 100644 --- a/tests/browser/fixtures/runtime.html +++ b/tests/browser/fixtures/runtime.html @@ -24,12 +24,12 @@ - + - + - +
    ApplicationVersionNetworkContainer IPContainer portLAN IP:PORT
    Media1/2 started
    Media1/2 started
    Plexup-to-datebridge172.17.0.232400/TCP10.0.0.10:32400
    Sonarrup-to-datebridge172.17.0.38989/TCP10.0.0.10:8989
    Updates1 update
    Updates1 update
    Toolboxupdate readybridge172.17.0.48080/TCP10.0.0.10:8080
    Empty0 members
    Empty0 members
    Orphanup-to-datebridge172.17.0.89000/TCP10.0.0.10:9000
    @@ -50,6 +50,7 @@ + diff --git a/tests/browser/helpers/docker-hidden-folder-context.mjs b/tests/browser/helpers/docker-hidden-folder-context.mjs new file mode 100644 index 00000000..1a526027 --- /dev/null +++ b/tests/browser/helpers/docker-hidden-folder-context.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; + +export const assertImmediateThemedUndo = async (page, folderId) => { + await page.evaluate(() => window.fixtureRuntime.hiddenFolderPersistence.defer()); + await page.evaluate((id) => { void window.fixtureRuntime.hiddenFolders.hideFolder(id); }, folderId); + assert.equal(await page.locator('#docker_list > tr.fv-folder-user-hidden').count(), 3); + assert.equal(await page.locator(`#docker_list > tr.folder-id-${folderId}`).isVisible(), false); + assert.equal(await page.evaluate(() => window.fixtureRuntime.hiddenFolderPersistence.isPending()), true); + const undoButton = page.locator('[data-fvplus-docker-hidden="undo"]'); + assert.equal(await undoButton.isVisible(), true, 'Undo must render before the deferred preference save resolves'); + const themedButton = await undoButton.evaluate((button) => { + const probe = document.createElement('div'); + probe.style.cssText = 'position:absolute;left:-9999px;border:1px solid var(--fvplus-ui-border-subtle);background:var(--fvplus-ui-control);color:var(--fvplus-ui-text-primary)'; + document.body.appendChild(probe); + const buttonStyle = getComputedStyle(button); + const probeStyle = getComputedStyle(probe); + const result = { + classApplied: button.classList.contains('fvplus-docker-visibility-button'), + borderColor: buttonStyle.borderTopColor, expectedBorderColor: probeStyle.borderTopColor, + backgroundColor: buttonStyle.backgroundColor, expectedBackgroundColor: probeStyle.backgroundColor, + color: buttonStyle.color, expectedColor: probeStyle.color + }; + probe.remove(); + return result; + }); + assert.equal(themedButton.classApplied, true); + assert.equal(themedButton.borderColor, themedButton.expectedBorderColor); + assert.equal(themedButton.backgroundColor, themedButton.expectedBackgroundColor); + assert.equal(themedButton.color, themedButton.expectedColor); + await page.evaluate(() => window.fixtureRuntime.hiddenFolderPersistence.resolve()); +}; diff --git a/tests/browser/helpers/docker-preview-context.mjs b/tests/browser/helpers/docker-preview-context.mjs index d13d3fc0..95cfaf34 100644 --- a/tests/browser/helpers/docker-preview-context.mjs +++ b/tests/browser/helpers/docker-preview-context.mjs @@ -83,3 +83,81 @@ export const exerciseDockerPreviewContextDiagnostics = async ({ page, baseUrl }) ); assert.equal(result.diagnostics.failureReasons['handler-missing'], 2); }; + +export const exerciseChildFolderPreviewContext = async ({ page, baseUrl }) => { + await page.goto(`${baseUrl}/docker-layout-stability`, { waitUntil: 'load' }); + const chip = page.locator('#fixture-child-folder-preview .fv-folder-preview-child'); + const snapshot = () => page.evaluate(() => window.fixtureChildFolderPreviewContext.snapshot()); + + let result = await snapshot(); + assert.deepEqual([result.chipCount, result.rowCount], [1, 3]); + assert.deepEqual(result.memberContextBindings, { default: 2, advanced: 0 }); + await page.locator('#fixture-child-folder-preview .fv-nested-preview-item').first().click(); + result = await snapshot(); + assert.deepEqual(result.memberContextClicks, { default: 1, advanced: 0 }); + + await chip.click(); + result = await snapshot(); + assert.equal(result.menuCount, 1, 'normal click must open the child-folder menu'); + assert.ok(result.menuLeft > 0); + assert.ok(result.menuTop > 0); + assert.deepEqual(result.menuActions, ['Expand to folder', 'Edit folder', 'Open folder actions']); + await page.getByRole('menuitem', { name: 'Edit folder' }).click(); + result = await snapshot(); + assert.deepEqual([result.menuCount, result.editCount], [0, 1]); + + await chip.focus(); + await chip.press('Enter'); + await page.waitForFunction(() => document.activeElement?.textContent?.includes('Expand to folder')); + result = await snapshot(); + assert.equal(result.menuCount, 1, 'keyboard activation must open the child-folder menu'); + assert.equal(result.focusedAction, 'Expand to folder'); + assert.ok(result.menuLeft > 0, 'keyboard activation must use the chip geometry'); + assert.ok(result.menuTop > 0, 'keyboard activation must use the chip geometry'); + await page.keyboard.press('Escape'); + + await chip.click({ button: 'right' }); + result = await snapshot(); + assert.equal(result.menuCount, 1, 'right-click must keep opening the child-folder menu'); + await page.getByRole('menuitem', { name: 'Open folder actions' }).click(); + result = await snapshot(); + assert.deepEqual([result.menuCount, result.actionCount], [0, 1]); + + await page.evaluate(() => window.fixtureChildFolderPreviewContext.dispatchTouchClick()); + result = await snapshot(); + assert.equal(result.menuCount, 1, 'touch-like activation must open the child-folder menu'); + const expectedTouchLeft = Math.max(8, Math.min(321, result.viewportWidth - result.menuWidth - 8)); + const expectedTouchTop = Math.max(8, Math.min(145, result.viewportHeight - result.menuHeight - 8)); + assert.deepEqual([result.menuLeft, result.menuTop], [expectedTouchLeft, expectedTouchTop]); + assert.deepEqual( + [result.diagnostics.childFolderPreview.counters.chipsRendered, + result.diagnostics.childFolderPreview.counters.bindings, + result.diagnostics.childFolderPreview.counters.menuOpenAttempts, + result.diagnostics.childFolderPreview.counters.menuOpens, + result.diagnostics.childFolderPreview.counters.menuOpenFailures], + [1, 1, 4, 4, 0] + ); + assert.deepEqual( + result.diagnostics.childFolderPreview.inputMethods, + { mouse: 1, keyboard: 1, contextmenu: 1, touch: 1, unknown: 0 } + ); + assert.doesNotMatch( + JSON.stringify(result.diagnostics.childFolderPreview), + /Private parent fixture|Private child fixture|fixture-parent|fixture-child/ + ); + + await page.evaluate(() => window.fixtureChildFolderPreviewContext.rerenderMemberContext(2, 'advanced')); + await page.locator('#fixture-child-folder-preview .fv-nested-preview-item').first().click(); + result = await snapshot(); + assert.deepEqual(result.memberContextBindings, { default: 2, advanced: 2 }); + assert.deepEqual(result.memberContextClicks, { default: 1, advanced: 1 }); + assert.equal(result.listViewMode, 'advanced'); + + await page.evaluate(() => window.fixtureChildFolderPreviewContext.rerenderMemberContext(1, 'advanced')); + await page.locator('#fixture-child-folder-preview .fv-nested-preview-item').nth(1).click(); + result = await snapshot(); + assert.deepEqual(result.memberContextBindings, { default: 4, advanced: 2 }); + assert.deepEqual(result.memberContextClicks, { default: 2, advanced: 1 }); + assert.equal(result.listViewMode, 'advanced'); + assert.equal(result.memberContextAudits, 3); +}; diff --git a/tests/dashboard-lifecycle-reconciliation.test.mjs b/tests/dashboard-lifecycle-reconciliation.test.mjs index 3db18721..52309b95 100644 --- a/tests/dashboard-lifecycle-reconciliation.test.mjs +++ b/tests/dashboard-lifecycle-reconciliation.test.mjs @@ -160,6 +160,114 @@ test('Docker page lifecycle defaults resolve container ids and clear expanded pr assert.equal(finalized.details.remainingBusyPreviewActionIconCount, 0); }); +test('Docker restart lifecycle keeps its pending surface through the initial running snapshot', async () => { + const scheduled = []; + const finalized = []; + const api = reconcileModule.createApi({ + window: { + setTimeout(handler, delayMs) { + scheduled.push({ handler, delayMs }); + return scheduled.length; + } + }, + refreshDockerRuntimeStateInPlace: async () => true, + isDockerLifecycleStateSettled: () => true, + getDockerLifecycleStateSnapshot: () => ({ state: 'running', active: true, paused: false }), + finalizeDockerLifecycleSurface: (request, outcome) => finalized.push({ request, outcome }), + lifecycleRefreshDelaysMs: [0, 750] + }); + + api.runDockerLifecycleRefresh({ action: 'restart', container: 'abc123def456' }); + scheduled[0].handler(); + await flushPromises(); + assert.equal(finalized.length, 0); + + scheduled[1].handler(); + await flushPromises(); + assert.equal(finalized.length, 1); + assert.equal(finalized[0].outcome.settled, true); + assert.equal(finalized[0].outcome.attempt, 2); +}); + +test('Docker native lifecycle hook marks and restores a matching compact preview status icon', async () => { + const scheduled = []; + const iconAttributes = new Map([['class', 'fa fa-play fv-preview-status-started']]); + const surfaceAttributes = new Map([ + ['data-fv-container-id', 'abc123def456'], + ['data-fv-container-name', 'example-container'] + ]); + const icon = { + getAttribute: (name) => iconAttributes.get(name) || '', + setAttribute: (name, value) => iconAttributes.set(name, String(value)), + hasAttribute: (name) => iconAttributes.has(name), + removeAttribute: (name) => iconAttributes.delete(name) + }; + const surface = { + getAttribute: (name) => surfaceAttributes.get(name) || '', + setAttribute: (name, value) => surfaceAttributes.set(name, String(value)), + removeAttribute: (name) => surfaceAttributes.delete(name), + querySelectorAll: () => [icon] + }; + const document = { + querySelectorAll(selector) { + if (selector.includes('data-fv-container-id')) return [surface]; + if (selector.includes('data-fv-lifecycle-pending')) { + return surfaceAttributes.get('data-fv-lifecycle-pending') === 'true' ? [surface] : []; + } + if (selector.includes('data-fv-lifecycle-icon-class')) { + return iconAttributes.has('data-fv-lifecycle-icon-class') ? [icon] : []; + } + return []; + } + }; + const entry = { + shortId: 'abc123def456', + name: 'example-container', + info: { Id: 'abc123def4567890', Name: 'example-container', State: { Running: true, Paused: false } } + }; + const window = { + eventControl: (...args) => { + window[args[1]](); + return true; + }, + setTimeout(handler, delayMs) { + scheduled.push({ handler, delayMs }); + return scheduled.length; + } + }; + const api = reconcileModule.createApi({ + window, + document, + getDockerRuntimeInfoEntries: () => [entry], + refreshDockerRuntimeStateInPlace: async () => true, + syncDockerVisibleFoldersFromRuntimeCache: () => true, + lifecycleRefreshDelaysMs: [0], + getDockerHostGuardsApi: () => ({ + wrapHostHook(name, handler) { + const original = window[name]; + window[name] = (...args) => handler({ + args, + invokeOriginal: (...overrideArgs) => original(...overrideArgs) + }); + } + }) + }); + + api.bindLifecycleEventControlPatch(); + window.eventControl({ action: 'restart', container: 'abc123def456' }, 'loadlist'); + assert.match(iconAttributes.get('class'), /fa-refresh/); + assert.match(iconAttributes.get('class'), /fa-spin/); + assert.equal(surfaceAttributes.get('aria-busy'), 'true'); + assert.equal(surfaceAttributes.get('data-fv-lifecycle-action'), 'restart'); + + scheduled[0].handler(); + await flushPromises(); + assert.equal(iconAttributes.get('class'), 'fa fa-play fv-preview-status-started'); + assert.equal(surfaceAttributes.has('aria-busy'), false); + assert.equal(surfaceAttributes.has('data-fv-lifecycle-action'), false); + assert.equal(iconAttributes.has('data-fv-lifecycle-icon-class'), false); +}); + test('Dashboard stop followed by start cancels the stale stop tail and converges to running', async () => { const scheduled = []; const refreshStates = [false, false, true]; diff --git a/tests/diagnostics-summary-contract.test.mjs b/tests/diagnostics-summary-contract.test.mjs index b1827244..c595dce7 100644 --- a/tests/diagnostics-summary-contract.test.mjs +++ b/tests/diagnostics-summary-contract.test.mjs @@ -60,6 +60,7 @@ const buildIntegrityChecks = (overrides = {}) => ({ invalidAutoRules: { count: 0, rules: [] }, missingManualOrderIds: { count: 0, ids: [] }, missingPinnedFolderIds: { count: 0, ids: [] }, + missingHiddenFolderIds: { count: 0, ids: [] }, duplicateAssignments: { explicit: { count: 0, examples: [] }, regex: { count: 0, examples: [] }, @@ -124,3 +125,14 @@ test('diagnostics summary still surfaces concrete path issues first', () => { assert.ok(dockerCard, 'docker summary card is missing'); assert.equal(dockerCard.detail, 'Missing /boot/config/plugins/folderview.plus/docker.folder.json'); }); + +test('diagnostics summary identifies stale hidden-folder preferences', () => { + const summary = runOverviewSummary({ + typesData: buildTypesData(buildIntegrityChecks({ + issuesCount: 1, + missingHiddenFolderIds: { count: 1, ids: ['removed-folder'] } + })) + }); + const dockerCard = summary.cards.find((card) => card.key === 'docker'); + assert.equal(dockerCard?.detail, '1 hidden folder id no longer matches an existing folder.'); +}); diff --git a/tests/docker-clone-branch-regression.test.mjs b/tests/docker-clone-branch-regression.test.mjs index 27d6860a..36218aa4 100644 --- a/tests/docker-clone-branch-regression.test.mjs +++ b/tests/docker-clone-branch-regression.test.mjs @@ -76,7 +76,8 @@ test('docker clone payload builder deep-clones mutable folder fields', () => { icon: 'icon.svg', settings: { layout: 'grid', - nested: { enabled: true } + nested: { enabled: true }, + webui_profiles: [{ id: 'media', name: 'Media tools', containers: ['plex'] }] }, regex: '^media', containers: ['plex'], @@ -98,6 +99,8 @@ test('docker clone payload builder deep-clones mutable folder fields', () => { assert.equal(payload.parentId, 'child-parent'); assert.notStrictEqual(payload.settings, source.settings); assert.notStrictEqual(payload.settings.nested, source.settings.nested); + assert.notStrictEqual(payload.settings.webui_profiles, source.settings.webui_profiles); + assert.deepEqual(payload.settings.webui_profiles, source.settings.webui_profiles); assert.notStrictEqual(payload.containers, source.containers); assert.notStrictEqual(payload.actions, source.actions); assert.notStrictEqual(payload.actions[0], source.actions[0]); diff --git a/tests/docker-folder-context-feature-actions.test.mjs b/tests/docker-folder-context-feature-actions.test.mjs index 1d45ef98..9920e651 100644 --- a/tests/docker-folder-context-feature-actions.test.mjs +++ b/tests/docker-folder-context-feature-actions.test.mjs @@ -19,17 +19,23 @@ const runtimeSharedControlsScript = fs.readFileSync( path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.shared-controls.js'), 'utf8' ); +const webuiProfilesScript = fs.readFileSync( + path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.webui-profiles.js'), + 'utf8' +); const runtimeSharedControls = require(path.join( repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.shared-controls.js' )); +const dockerRuntimeActions = require('../src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.actions.js'); +const webuiProfiles = require('../src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.webui-profiles.js'); test('docker folder context supports open-all-webui actions with scoped options', () => { assert.match(dockerScript, /const collectFolderWebuiTargets = \(id, includeDescendants = true, runningOnly = true\) => \{/); assert.match(dockerRuntimeActionsScript, /const collectFolderWebuiTargets = \(id, includeDescendants = true, runningOnly = true\) =>/); assert.match(runtimeSharedControlsScript, /const openWebuiPopupWindow = \(url, targetName = '_blank'\) =>/); - assert.match(dockerScript, /const openFolderWebuisFromMenu = \(id, runningOnly = true, includeDescendants = false\) => \{/); - assert.match(dockerRuntimeActionsScript, /const openFolderWebuisFromMenu = \(id, runningOnly = true, includeDescendants = false\) =>/); + assert.match(dockerScript, /const openFolderWebuisFromMenu = \(id, runningOnly = true, includeDescendants = false, profileId = ''\) =>/); + assert.match(dockerRuntimeActionsScript, /const openFolderWebuisFromMenu = \(id, runningOnly = true, includeDescendants = false, profileId = ''\) =>/); assert.match(dockerScript, /Open all WebUIs/); assert.match(dockerScript, /collectFolderWebuiTargets\(id, false, true\)/); assert.match(dockerRuntimeActionsScript, /entry\?\.state === true && entry\?\.pause !== true/); @@ -40,6 +46,42 @@ test('docker folder context supports open-all-webui actions with scoped options' assert.match(dockerRuntimeActionsScript, /Blocked WebUIs \(manual open\)/); assert.match(dockerScript, /dockerRuntimeInfoByName/); assert.match(dockerScript, /openFolderWebuisFromMenu\(id, true, false\)/); + assert.match(dockerScript, /appendRuntimeMenuItems/); + assert.match(webuiProfilesScript, /docker\.webui-profiles\.open/); + assert.match(dockerScript, /editFolder\(id, \{ section: 'webuiProfiles' \}\)/); +}); + +test('custom WebUI profile launches only selected running direct members', async () => { + const opened = []; + const api = dockerRuntimeActions.createApi({ + window: { setTimeout, location: { host: 'tower.local' } }, + document: {}, + webuiProfiles, + getGlobalFolders: () => ({ + media: { + settings: { + webui_profiles: [{ id: 'media-tools', name: 'Media tools', containers: ['Plex', 'Tautulli', 'Stopped'] }] + } + } + }), + getScopedRuntimeContainersForFolder: () => ({ + Plex: { state: true, pause: false, webui: 'http://plex/' }, + Tautulli: { state: true, pause: false, webui: 'http://tautulli/' }, + Stopped: { state: false, pause: false, webui: 'http://stopped/' }, + Other: { state: true, pause: false, webui: 'http://other/' } + }), + getSafeWebuiUrl: (value) => value, + openWebuiPopupWindow: (url) => { opened.push(url); return true; }, + runDockerGuardedAction: async (_name, action) => action() + }); + + api.openFolderWebuisFromMenu('media', true, false, 'media-tools'); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(opened, ['http://plex/', 'http://tautulli/']); + + api.openFolderWebuisFromMenu('media', true, false, 'missing-profile'); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(opened, ['http://plex/', 'http://tautulli/']); }); test('open-all WebUI popup detection uses a secured blank handle before navigation', () => { diff --git a/tests/docker-folder-row-quick-actions.test.mjs b/tests/docker-folder-row-quick-actions.test.mjs index da48bcd5..8c969043 100644 --- a/tests/docker-folder-row-quick-actions.test.mjs +++ b/tests/docker-folder-row-quick-actions.test.mjs @@ -8,6 +8,10 @@ const dockerScript = fs.readFileSync( path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js'), 'utf8' ); +const dockerHiddenFoldersScript = fs.readFileSync( + path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hidden-folders.js'), + 'utf8' +); const dockerPreviewActionsScript = fs.readFileSync( path.join(repoRoot, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js'), 'utf8' @@ -46,13 +50,18 @@ const dockerFolderSameLevelMoveBlock = extractConstFunctionBlock( 'ensureDockerFolderUnlocked' ); -test('docker context menu keeps focus/pin/lock quick actions at the top', () => { +test('docker context menu keeps focus/pin/lock/hide quick actions at the top', () => { assert.match(dockerScript, /text:\s*focused[\s\S]*getDockerMenuLabel\('clear-focus-folder',\s*'Clear focus'\)/); assert.match(dockerScript, /text:\s*pinned[\s\S]*getDockerMenuLabel\('unpin-folder',\s*'Unpin folder'\)/); assert.match(dockerScript, /text:\s*locked[\s\S]*getDockerMenuLabel\('unlock-folder',\s*'Unlock folder'\)/); assert.match(dockerScript, /toggleDockerFolderFocus\(id\)/); assert.match(dockerScript, /toggleDockerFolderPin\(id\)/); assert.match(dockerScript, /toggleDockerFolderLock\(id\)/); + assert.match(dockerHiddenFoldersScript, /translate\('docker\.folder\.hide',\s*'Hide folder'\)/); + assert.match(dockerHiddenFoldersScript, /hiddenOwnerId \? restoreFolder\(hiddenOwnerId\) : hideFolder\(id\)/); + assert.match(dockerScript, /minimumItems:\s*4/); + assert.match(dockerScript, /maximumItems:\s*4/); + assert.match(dockerHiddenFoldersScript, /svgIcon\(\$icon\.hasClass\('fa-eye-slash'\)/); assert.match(dockerScript, /queueDockerFolderContextQuickIcons\(/); assert.match(dockerScript, /createDockerContextMenuQuickStripAdapter/); assert.match(dockerScript, /dockerContextQuickStripAdapter/); @@ -248,6 +257,20 @@ test('docker lifecycle reconciliation restores expanded preview action icons fro assert.match(dockerScript, /getDockerRuntimeInfoEntries: \(\) => Object\.values\(dockerRuntimeInfoByName \|\| \{\}\),/); assert.match(dockerScript, /syncDockerVisibleFoldersFromRuntimeCache: \(changedNames = null\) => syncDockerVisibleFoldersFromRuntimeCache\(changedNames\),/); assert.match(dockerReconcileScript, /remainingBusyPreviewActionIconCount:/); + assert.match(dockerScript, /\.attr\('data-fv-container-id', String\(ctid \|\| ''\)\.trim\(\)\)/); + assert.match(dockerScript, /\.attr\('data-fv-container-name', String\(previewEntry\?\.name \|\| ''\)\.trim\(\)\)/); + assert.match(dockerReconcileScript, /const markDockerLifecycleSurfacePending = \(request = \{\}\) =>/); + assert.match(dockerReconcileScript, /const clearDockerLifecycleSurfacePending = \(request = \{\}\) =>/); + assert.match(dockerReconcileScript, /const restartWarmupPending = action === 'restart'/); + assert.match(dockerPreviewActionsScript, /\.attr\('data-fv-container-id', String\(options\.ctid \|\| ''\)\.trim\(\)\)/); + assert.match(dockerPreviewActionsScript, /\.attr\('data-fv-container-name', String\(options\.containerName \|\| ''\)\.trim\(\)\)/); + assert.match(dockerScript, /context: folder\.settings\.context, ctid, containerName: previewEntry\?\.name, autostart/); + assert.match(dockerScript, /span\.hand:last`\)[\s\S]*?\.attr\('data-fv-container-id', String\(ctid \|\| ''\)\.trim\(\)\)[\s\S]*?\.attr\('data-fv-container-name', String\(previewEntry\?\.name \|\| ''\)\.trim\(\)\)/); + assert.match(dockerScript, /lstSpan\.children\('span\.inner:last'\)[\s\S]*?\.attr\('data-fv-container-id', String\(ctid \|\| ''\)\.trim\(\)\)[\s\S]*?\.attr\('data-fv-container-name', String\(previewEntry\?\.name \|\| ''\)\.trim\(\)\)/); + assert.match(dockerCss, /\.folder-preview i\.fv-preview-lifecycle-pending\s*\{[^}]*var\(--fvplus-folder-status-started,\s*var\(--fvplus-status-started\)\)/); + assert.match(dockerCss, /data-fv-lifecycle-action="restart"[^}]*fv-preview-lifecycle-pending[\s\S]*?var\(--fvplus-folder-status-started,\s*var\(--fvplus-status-started\)\)/); + assert.match(dockerCss, /data-fv-lifecycle-action="pause"[^}]*fv-preview-lifecycle-pending\s*\{[^}]*var\(--fvplus-folder-status-paused,\s*var\(--fvplus-status-paused\)\)/); + assert.match(dockerCss, /data-fv-lifecycle-action="stop"[^}]*fv-preview-lifecycle-pending\s*\{[^}]*var\(--fvplus-folder-status-stopped,\s*var\(--fvplus-status-stopped\)\)/); }); test('docker incremental lifecycle sync refreshes initialized preview menus from canonical runtime state', () => { diff --git a/tests/docker-hidden-folder-visibility.test.mjs b/tests/docker-hidden-folder-visibility.test.mjs new file mode 100644 index 00000000..71b51960 --- /dev/null +++ b/tests/docker-hidden-folder-visibility.test.mjs @@ -0,0 +1,136 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const root = path.resolve(process.cwd()); +const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), 'utf8'); +const dockerJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js'); +const hiddenFoldersJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hidden-folders.js'); +const require = createRequire(import.meta.url); +const hiddenFoldersModule = require(path.join(root, 'src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hidden-folders.js')); +const prefsJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils-prefs.js'); +const libPrefsPhp = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.prefs.php'); +const validationPhp = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.validation.php'); +const diagnosticsPhp = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.diagnostics-support-bundle.php'); +const pageDiagnosticsJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.page-diagnostics.js'); + +test('hidden folder ids use normalized conflict-safe server preferences', () => { + assert.match(libPrefsPhp, /'hiddenFolderIds'\s*=>\s*\[\]/); + assert.match(libPrefsPhp, /\$normalized\['hiddenFolderIds'\]\s*=\s*normalizeStringIdList/); + assert.match(validationPhp, /fvplus_validation_assert_list_of_scalarish\(\$payload\['hiddenFolderIds'\], 'hiddenFolderIds'/); + assert.match(prefsJs, /const hiddenFolderIds = normalizeStringIdList\(incoming\.hiddenFolderIds\)/); + assert.match(hiddenFoldersJs, /savePrefs\(\{ hiddenFolderIds \}/); + assert.match(hiddenFoldersJs, /safeActionRunner\.run\('docker-hidden-folders'/); + assert.match(hiddenFoldersJs, /queueIfBusy:\s*true/); + assert.match(hiddenFoldersJs, /if \(!intent\.isLatest\(\)\) return/); + assert.match(hiddenFoldersJs, /pendingIdsOverride = \{ ids: normalizeIds\(nextIds\), expiresAt:/); + assert.match(dockerJs, /dockerHiddenFoldersApi\.reconcilePrefs\(applyDockerPinnedFolderPrefsOverride/); +}); + +test('hidden parent folders suppress their complete visual branch without changing membership', () => { + assert.match(hiddenFoldersJs, /cursor = normalizeParentId\(folder\?\.parentId \|\| folder\?\.parent_id \|\| ''\)/); + assert.match(hiddenFoldersJs, /const ownerId = folderId \|\| readFolderOwnerFromRow\(row\)/); + assert.match(hiddenFoldersJs, /\$row\.toggleClass\('fv-folder-user-hidden', hidden\)/); + assert.match(hiddenFoldersJs, /if \(hidden && getOwnerId\(getFocusedFolderId\(\)\)\) clearFocusedFolder\(\)/); +}); + +test('hidden-folder hierarchy resolves explicit ancestors without exposing identities', () => { + const prefs = { hiddenFolderIds: ['root', 'root', ''] }; + const api = hiddenFoldersModule.createApi({ + getPrefs: () => prefs, + getFolders: () => ({ root: {}, child: { parentId: 'root' }, other: {} }), + normalizeParentId: (value) => String(value || '') + }); + assert.deepEqual(api.normalizeIds(prefs.hiddenFolderIds), ['root']); + assert.equal(api.getOwnerId('child'), 'root'); + assert.equal(api.getOwnerId('other'), ''); + assert.deepEqual(api.getSummary().effectiveIds.sort(), ['child', 'root']); +}); + +test('newer optimistic hidden-folder intent survives an older preference broadcast', () => { + let prefs = { hiddenFolderIds: [] }; + const emptyCollection = { toggleClass: () => emptyCollection, each: () => emptyCollection }; + const api = hiddenFoldersModule.createApi({ + $: () => emptyCollection, + getPrefs: () => prefs, + setPrefs: (value) => { prefs = value; }, + normalizePrefs: (value) => ({ ...value, hiddenFolderIds: Array.isArray(value.hiddenFolderIds) ? value.hiddenFolderIds : [] }), + getFolders: () => ({ root: {} }), + runtimeStateStore: { set: () => {} }, + safeActionRunner: { + run: (_key, _action, settings) => { + settings.onIntent({ isLatest: () => true }); + return new Promise(() => {}); + } + } + }); + void api.hideFolder('root'); + assert.deepEqual(api.reconcilePrefs({ hiddenFolderIds: [] }).hiddenFolderIds, ['root']); +}); + +test('hide offers Undo before deferred persistence resolves', async () => { + let prefs = { hiddenFolderIds: [] }; + let resolveSave; + const undoOffers = []; + const emptyCollection = { toggleClass: () => emptyCollection, each: () => emptyCollection }; + const api = hiddenFoldersModule.createApi({ + $: () => emptyCollection, + getPrefs: () => prefs, + setPrefs: (value) => { prefs = value; }, + normalizePrefs: (value) => ({ ...value, hiddenFolderIds: Array.isArray(value.hiddenFolderIds) ? value.hiddenFolderIds : [] }), + getFolders: () => ({ root: { name: 'Root' } }), + runtimeStateStore: { set: () => {} }, + safeActionRunner: { + run: async (_key, action, settings) => { + const intent = { isLatest: () => true }; + settings.onIntent(intent); + return action(intent); + } + }, + savePrefs: () => new Promise((resolve) => { resolveSave = resolve; }), + offerUndo: (payload) => undoOffers.push(payload) + }); + const pending = api.hideFolder('root'); + assert.deepEqual(prefs.hiddenFolderIds, ['root']); + assert.deepEqual(undoOffers, [{ folderId: 'root', folderName: 'Root' }]); + resolveSave({ hiddenFolderIds: ['root'] }); + assert.equal(await pending, true); + assert.equal(undoOffers.length, 1); +}); + +test('failed hide dismisses only its optimistic Undo notice', async () => { + let prefs = { hiddenFolderIds: [] }; + const dismissed = []; + const emptyCollection = { toggleClass: () => emptyCollection, each: () => emptyCollection }; + const api = hiddenFoldersModule.createApi({ + $: () => emptyCollection, + getPrefs: () => prefs, + setPrefs: (value) => { prefs = value; }, + normalizePrefs: (value) => ({ ...value, hiddenFolderIds: Array.isArray(value.hiddenFolderIds) ? value.hiddenFolderIds : [] }), + getFolders: () => ({ root: { name: 'Root' } }), + runtimeStateStore: { set: () => {} }, + safeActionRunner: { + run: async (_key, action, settings) => { + const intent = { isLatest: () => true }; + settings.onIntent(intent); + return action(intent); + } + }, + savePrefs: async () => { throw new Error('save failed'); }, + fetchPrefs: async () => ({ hiddenFolderIds: [] }), + dismissUndo: (folderId) => dismissed.push(folderId) + }); + assert.equal(await api.hideFolder('root'), false); + assert.deepEqual(prefs.hiddenFolderIds, []); + assert.deepEqual(dismissed, ['root']); +}); + +test('hidden-folder diagnostics retain counts and hash folder identities', () => { + assert.match(diagnosticsPhp, /prefs\.hiddenFolders\.\*/); + assert.match(diagnosticsPhp, /diagnosticsSupportBundleRedactFolderIdList/); + assert.match(pageDiagnosticsJs, /hiddenFolderSelections/); + assert.match(pageDiagnosticsJs, /effectivelyHiddenFolders/); + assert.match(pageDiagnosticsJs, /revealHiddenFolders/); +}); diff --git a/tests/docker-preview-overflow-feature.test.mjs b/tests/docker-preview-overflow-feature.test.mjs index 9f85df68..a423f073 100644 --- a/tests/docker-preview-overflow-feature.test.mjs +++ b/tests/docker-preview-overflow-feature.test.mjs @@ -13,6 +13,7 @@ const folderPreviewModelJs = read('src/folderview.plus/usr/local/emhttp/plugins/ const folderEditorSharedJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.shared.js'); const dockerJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js'); const dockerPreviewActionsScript = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js'); +const dockerChildFolderPreviewMenuJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.child-folder-preview-menu.js'); const sharedRuntimeJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.shared.js'); const dockerRuntimeHierarchyJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js'); const dockerCss = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/styles/docker.css'); @@ -156,20 +157,36 @@ test('docker runtime applies preview row layout limits and keeps compact preview assert.match(folderPreviewModelJs, /const formatRuntimeCountLabel = \(startedCount, memberCount\) => \{/); assert.match(dockerRuntimeHierarchyJs, /previewModelModule\.createChildFolderPreviewModel\(\{/); assert.match(dockerRuntimeHierarchyJs, /runtimeCountLabel/); - assert.match(dockerRuntimeHierarchyJs, /const showFolderPreviewContextMenu = \(event, rootId, childId, childName\) => \{/); - assert.match(dockerRuntimeHierarchyJs, /addAction\('Expand to folder', 'fa-level-down'/); - assert.match(dockerRuntimeHierarchyJs, /addAction\('Edit folder', 'fa-pencil'/); - assert.match(dockerRuntimeHierarchyJs, /addAction\('Open folder actions', 'fa-bars'/); - assert.match(dockerRuntimeHierarchyJs, /addAction\('Open folder actions', 'fa-bars', \(\) => \{[\s\S]*expandFolderPathToChild\(rootId, childId\);[\s\S]*scrollFolderRowIntoView\(childId\);[\s\S]*openFolderActions\(childId\);/); + assert.match(dockerChildFolderPreviewMenuJs, /const resolveInputMethod = \(event = null\) => \{/); + assert.match(dockerChildFolderPreviewMenuJs, /const resolveActivationPoint = \(event = null, \$item = null\) => \{/); + assert.match(dockerChildFolderPreviewMenuJs, /const show = \(options = \{\}\) => \{/); + assert.match(dockerChildFolderPreviewMenuJs, /addAction\('Expand to folder', 'fa-level-down'/); + assert.match(dockerChildFolderPreviewMenuJs, /addAction\('Edit folder', 'fa-pencil'/); + assert.match(dockerChildFolderPreviewMenuJs, /addAction\('Open folder actions', 'fa-bars'/); + assert.match(dockerChildFolderPreviewMenuJs, /addAction\('Open folder actions', 'fa-bars', \(\) => \{[\s\S]*expandFolderPathToChild\(rootId, childId\);[\s\S]*scrollFolderRowIntoView\(childId\);[\s\S]*openFolderActions\(childId\);/); assert.match(dockerRuntimeHierarchyJs, /data-folder-preview-root/); assert.match(dockerRuntimeHierarchyJs, /data-folder-preview-child/); assert.match(dockerRuntimeHierarchyJs, /data-folder-preview-depth/); + assert.match(dockerRuntimeHierarchyJs, /aria-haspopup="menu"/); + assert.match(dockerRuntimeHierarchyJs, /childFolderPreviewMenuApi\?\.show\?\.\(\{/); + assert.match(dockerRuntimeHierarchyJs, /click\.fvChildFolderPreviewMenu keydown\.fvChildFolderPreviewMenu/); + assert.match(dockerRuntimeHierarchyJs, /contextmenu\.fvChildFolderPreviewMenu/); + assert.match(dockerRuntimeHierarchyJs, /recordChildFolderPreviewRender\(\);/); + assert.match(dockerRuntimeHierarchyJs, /recordChildFolderPreviewBinding\(\);/); + assert.doesNotMatch(dockerRuntimeHierarchyJs, /\$item\.on\('click keydown',[\s\S]*expandFolderPathToChild\(parentId, childId\);/); assert.match(dockerRuntimeHierarchyJs, /includeChildFolders\s*:\s*shouldHideNestedPreviewItems\(folder\?\.settings \|\| \{\}\)/); assert.match(dockerRuntimeHierarchyJs, /buildRuntimeContainerMapForFolder\(id, false\)/); + assert.match(dockerRuntimeHierarchyJs, /bindDockerNestedPreviewContext\(item, \$tooltipTrigger, entry, folder, id\);/); + assert.match(dockerRuntimeHierarchyJs, /auditDockerPreviewContextBridges\(\$preview, folder\?\.settings \|\| \{\}\);/); + assert.match(dockerJs, /const bindDockerNestedPreviewContext = \(\$item, \$tooltipTrigger, entry = \{\}, folder = \{\}, folderId = ''\) =>/); + assert.match(dockerPreviewActionsScript, /const findDockerNestedPreviewSourceRow = \(entry = \{\}\) =>/); + assert.match(dockerPreviewActionsScript, /const bindDockerNestedPreviewContext = \(options = \{\}\) =>/); + assert.match(dockerPreviewActionsScript, /findDockerNestedPreviewSourceRow\(options\.entry \|\| \{\}\)/); + assert.match(dockerJs, /dockerAdvancedPreviewContextBindersByName\.get\(containerName\)/); + assert.match(dockerJs, /bindAdvancedContext\(\$trigger, \{ folder, folderId \}\)/); assert.match(dockerRuntimeHierarchyJs, /const depthLimit = normalizeChildFolderPreviewDepth\(folder\?\.settings \|\| \{\}\);/); assert.match(dockerRuntimeHierarchyJs, /for \(const descendant of getFolderPreviewDescendants\(id, depthLimit\)\)/); assert.doesNotMatch(dockerRuntimeHierarchyJs, /for \(const childId of getFolderChildren\(id\)\) \{[\s\S]*buildChildFolderPreviewItem\(id, childId, childFolder\)/); - assert.match(dockerRuntimeHierarchyJs, /expandFolderPathToChild\(parentId, childId\);/); assert.match(dockerJs, /editFolder: \(id\) => editFolder\(id\)/); assert.match(dockerJs, /openFolderActions: \(id\) => \{/); assert.match(dockerJs, /const trigger = document\.getElementById\(String\(id \|\| ''\)\.trim\(\)\);/); @@ -178,6 +195,9 @@ test('docker runtime applies preview row layout limits and keeps compact preview assert.match(dockerJs, /const clientY = rect \? rect\.top \+ Math\.max\(1, rect\.height \/ 2\) : 0;/); assert.match(dockerJs, /trigger\.dispatchEvent\(new MouseEvent\('click'/); assert.match(dockerJs, /clientX,\s*[\r\n]+\s*clientY/); + assert.match(dockerJs, /recordChildFolderPreviewMenuOpen: \(details\) => getDockerPreviewActionsApi\(\)\?\.recordChildFolderPreviewMenuOpen\?\.\(details\)/); + assert.match(dockerPreviewActionsScript, /childFolderPreview:\s*\{/); + assert.match(dockerPreviewActionsScript, /recordChildFolderPreviewMenuOpen\(details = \{\}\) \{/); assert.match(dockerJs, /addDockerFolderContext\(id\);/); assert.match(dockerJs, /const maxItemsPerRow = Math\.max\(1,\s*getFolderPreviewItemsPerRow\(settings\)\)/); assert.match(dockerJs, /const \$measurement = availableWidth > 0/); diff --git a/tests/docker-runtime-action-bar.test.mjs b/tests/docker-runtime-action-bar.test.mjs index 2ababeb7..ff9a8b16 100644 --- a/tests/docker-runtime-action-bar.test.mjs +++ b/tests/docker-runtime-action-bar.test.mjs @@ -8,6 +8,7 @@ const repoRoot = path.resolve(process.cwd()); const read = (relativePath) => fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); const dockerJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.js'); const actionBarJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.action-bar.js'); +const hiddenFoldersJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hidden-folders.js'); const require = createRequire(import.meta.url); const actionBarModule = require(path.join( repoRoot, @@ -38,7 +39,9 @@ test('FolderView action bar exposes the complete folder and troubleshooting cont 'Health Issues', 'Manage Folders', 'View', - 'Tools' + 'Tools', + 'Hidden folders', + 'Restore all hidden folders' ]) { assert.ok( actionBarJs.includes(`'${label}'`) || actionBarJs.includes(`>${label}`), @@ -211,3 +214,29 @@ test('hiding empty folders reconciles an active empty-only filter before renderi assert.match(actionBarJs, /if \(reconcileFilterWithPrefs\(\)\) applyFilterState\(\)/); assert.match(actionBarJs, /actionMenuOpen = '';[\s\S]{0,1200}if \(tool === 'reset'\) resetView\(\)/); }); + +test('hidden folder recovery remains available outside the hidden runtime rows', () => { + assert.match(actionBarJs, /data-fvplus-docker-hidden="toggle-reveal"/); + assert.match(actionBarJs, /data-fvplus-docker-hidden="restore-all"/); + assert.match(actionBarJs, /All folder rows are hidden\./); + assert.match(actionBarJs, /data-fvplus-docker-hidden="undo"/); + assert.match(actionBarJs, /fvplus-docker-visibility-button/); + assert.match(actionBarJs, /const clearHiddenFolderUndo = \(folderId = ''\) =>/); + assert.match(actionBarJs, /setRevealHiddenFolders\(false\)/); + assert.doesNotMatch(actionBarJs, /resetView[\s\S]{0,500}restoreAllHiddenFolders/); + assert.match(dockerCss, /#docker_list > tr\.fv-folder-user-hidden\s*\{[\s\S]*display:\s*none !important/); + assert.match(dockerCss, /body\.fvplus-docker-reveal-hidden #docker_list > tr\.fv-folder-user-hidden/); + assert.match(dockerCss, /\.fvplus-docker-visibility-notice > \.fvplus-docker-visibility-button\s*\{[\s\S]*--fvplus-ui-border-subtle[\s\S]*--fvplus-ui-control[\s\S]*--fvplus-ui-text-primary/); + assert.match(hiddenFoldersJs, /const restoreAll = async \(\) =>/); + assert.match(hiddenFoldersJs, /hiddenFolderIds/); +}); + +test('hidden-folder Undo dismissal is scoped to the matching folder', () => { + const api = actionBarModule.createApi({ + window: { addEventListener() {}, setTimeout: () => 1, clearTimeout() {}, Element: null, HTMLElement: null }, + document: { addEventListener() {}, querySelectorAll: () => [], getElementById: () => null, querySelector: () => null } + }); + api.offerHiddenFolderUndo({ folderId: 'media', folderName: 'Media' }); + assert.equal(api.clearHiddenFolderUndo('updates'), false); + assert.equal(api.clearHiddenFolderUndo('media'), true); +}); diff --git a/tests/docker-runtime-shared-architecture.test.mjs b/tests/docker-runtime-shared-architecture.test.mjs index 1edc6fdd..bbab2e95 100644 --- a/tests/docker-runtime-shared-architecture.test.mjs +++ b/tests/docker-runtime-shared-architecture.test.mjs @@ -17,6 +17,7 @@ const runtimeSharedDiagnosticsJs = read('src/folderview.plus/usr/local/emhttp/pl const dockerModulesJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.modules.js'); const dockerRuntimeInfoJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.info.js'); const dockerPreviewActionsJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js'); +const dockerChildFolderPreviewMenuJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.child-folder-preview-menu.js'); const dockerRuntimeHierarchyJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js'); const dockerColumnControllerJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.column-controller.js'); const dockerRuntimeActionsJs = read('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.actions.js'); @@ -54,6 +55,7 @@ test('docker runtime page loads shared runtime module before docker modules/runt const modulesIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.modules.js'); const runtimeInfoIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.info.js'); const previewActionsIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.preview-actions.js'); + const childFolderPreviewMenuIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.child-folder-preview-menu.js'); const runtimeHierarchyIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.hierarchy.js'); const runtimeActionsIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.actions.js'); const hostGuardsIndex = dockerPage.indexOf('/plugins/folderview.plus/scripts/docker.runtime.host-guards.js'); @@ -78,6 +80,7 @@ test('docker runtime page loads shared runtime module before docker modules/runt assert.ok(modulesIndex >= 0, 'docker modules script include is missing'); assert.ok(runtimeInfoIndex >= 0, 'docker runtime info script include is missing'); assert.ok(previewActionsIndex >= 0, 'docker preview actions script include is missing'); + assert.ok(childFolderPreviewMenuIndex >= 0, 'child-folder preview menu script include is missing'); assert.ok(runtimeHierarchyIndex >= 0, 'docker hierarchy script include is missing'); assert.ok(runtimeActionsIndex >= 0, 'docker actions script include is missing'); assert.ok(hostGuardsIndex >= 0, 'docker host guards script include is missing'); @@ -106,7 +109,8 @@ test('docker runtime page loads shared runtime module before docker modules/runt assert.ok(hostAdapterIndex < orderingIndex && orderingIndex < runtimeIndex, 'shared ordering must load after the host adapter and before docker.js'); assert.ok(modulesIndex < runtimeInfoIndex, 'docker.modules.js must load before docker.runtime.info.js'); assert.ok(runtimeInfoIndex < previewActionsIndex, 'docker.runtime.info.js must load before docker.runtime.preview-actions.js'); - assert.ok(previewActionsIndex < runtimeHierarchyIndex, 'docker preview action helpers must load before docker.runtime.hierarchy.js'); + assert.ok(previewActionsIndex < childFolderPreviewMenuIndex, 'docker preview action helpers must load before child-folder preview menus'); + assert.ok(childFolderPreviewMenuIndex < runtimeHierarchyIndex, 'child-folder preview menus must load before docker.runtime.hierarchy.js'); assert.ok(runtimeHierarchyIndex < runtimeActionsIndex, 'docker hierarchy helpers must load before docker.runtime.actions.js'); assert.ok(runtimeActionsIndex < hostGuardsIndex, 'docker action helpers must load before docker.runtime.host-guards.js'); assert.ok(hostGuardsIndex < diagnosticsIndex, 'docker host guards must load before docker.runtime.diagnostics.js'); @@ -155,6 +159,10 @@ test('docker extracted helper modules export createApi entry points with safe gl assert.match(dockerPreviewActionsJs, /root\.FolderViewPlusDockerPreviewActions = factory\(\);/); assert.match(dockerPreviewActionsJs, /root\.FolderViewPlusDockerPreviewActionsModuleLoaded = true;/); assert.match(dockerPreviewActionsJs, /const createApi = \(deps = \{\}\) =>/); + assert.match(dockerChildFolderPreviewMenuJs, /^\/\/ @ts-check/m); + assert.match(dockerChildFolderPreviewMenuJs, /const fallbackWindow = typeof globalThis !== 'undefined'/); + assert.match(dockerChildFolderPreviewMenuJs, /root\.FolderViewPlusFoundationModules\.dockerChildFolderPreviewMenu = factory\(\);/); + assert.match(dockerChildFolderPreviewMenuJs, /const createApi = \(deps = \{\}\) =>/); assert.match(dockerRuntimeHierarchyJs, /^\/\/ @ts-check/m); assert.match(dockerRuntimeHierarchyJs, /const fallbackWindow = typeof globalThis !== 'undefined'/); assert.match(dockerRuntimeHierarchyJs, /root\.FolderViewPlusDockerRuntimeHierarchy = factory\(\);/); @@ -213,6 +221,7 @@ test('docker runtime consumes shared state store and guarded async action wrappe assert.match(dockerJs, /const dockerRuntimeShared = window\.FolderViewDockerRuntimeShared \|\| \{\};/); assert.match(dockerJs, /const dockerRuntimeInfoModule = window\.FolderViewPlusDockerRuntimeInfo \|\| null;/); assert.match(dockerJs, /const dockerPreviewActionsModule = window\.FolderViewPlusDockerPreviewActions \|\| null;/); + assert.match(dockerJs, /const dockerChildFolderPreviewMenuModule = window\.FolderViewPlusFoundationModules\?\.dockerChildFolderPreviewMenu \|\| null;/); assert.match(dockerJs, /const dockerRuntimeHierarchyModule = window\.FolderViewPlusDockerRuntimeHierarchy \|\| null;/); assert.match(dockerJs, /const dockerRuntimeActionsModule = window\.FolderViewPlusDockerRuntimeActions \|\| null;/); assert.match(dockerJs, /const dockerHostGuardsModule = window\.FolderViewPlusDockerHostGuards \|\| null;/); diff --git a/tests/folder-editor-route-guard.test.mjs b/tests/folder-editor-route-guard.test.mjs index 9b0eb9d6..491615b7 100644 --- a/tests/folder-editor-route-guard.test.mjs +++ b/tests/folder-editor-route-guard.test.mjs @@ -92,7 +92,7 @@ test('folder editor URLs duplicate folder identity into the hash for navigation- assert.match(dockerRuntimeActionsJs, /const EDITOR_DEBUG_LAUNCH_STORAGE_KEY = 'fv\.folder\.editor\.debug\.launch\.v1';/); assert.match(dockerRuntimeActionsJs, /const recordFolderEditorLaunchDebug = \(sourcePage, folderType, id, targetUrl\) =>/); assert.match(dockerRuntimeActionsJs, /win\.name = `\$\{EDITOR_WINDOW_NAME_PREFIX\}\$\{payload\}`;/); - assert.match(dockerJs, /const editFolder = \(id\) => \{[\s\S]*actionsApi\.editFolder\(id\);/); + assert.match(dockerJs, /const editFolder = \(id, options = \{\}\) => getDockerRuntimeActionsApi\(\)\?\.editFolder\?\.\(id, options\);/); assert.match(vmJs, /const EDITOR_WINDOW_NAME_PREFIX = 'fv\.folder\.editor\.v1:'/); assert.match(vmJs, /const EDITOR_BOOTSTRAP_COOKIE_NAME = 'fv_folder_editor_bootstrap';/); assert.match(vmJs, /const EDITOR_DEBUG_LAUNCH_STORAGE_KEY = 'fv\.folder\.editor\.debug\.launch\.v1';/); diff --git a/tests/folder-editor-settings-contract.test.mjs b/tests/folder-editor-settings-contract.test.mjs index 476f1cee..847d65bf 100644 --- a/tests/folder-editor-settings-contract.test.mjs +++ b/tests/folder-editor-settings-contract.test.mjs @@ -66,7 +66,9 @@ test('modern folder editor schema fields all exist in the page markup and metada test('modern folder editor load path covers every schema-backed field', () => { assert.match(folderJs, /populateParentFolderOptions\(/); - const fieldsLoadedViaBinding = schemaFields.filter((fieldName) => fieldName !== 'parent_folder_id'); + const fieldsLoadedViaBinding = schemaFields.filter( + (fieldName) => !['parent_folder_id', 'webui_profiles'].includes(fieldName) + ); fieldsLoadedViaBinding.forEach((fieldName) => { assert.match( folderJs, @@ -74,6 +76,7 @@ test('modern folder editor load path covers every schema-backed field', () => { `Expected modern folder editor loader to bind ${fieldName}.` ); }); + assert.match(folderJs, /getFolderWebuiProfilesApi\(\)\?\.hydrate\(normalizedFolder\.settings\.webui_profiles/); assert.match(folderJs, /const getFolderEditorStateApi = \(\) =>/); assert.match(folderJs, /const restoreSectionSavedValues = \(sectionKey\) => \{\s*getFolderEditorStateApi\(\)\?\.restoreSectionSavedValues\(sectionKey\);\s*\};/); diff --git a/tests/folder-settings-transfer.test.mjs b/tests/folder-settings-transfer.test.mjs index c5b57113..9d064b95 100644 --- a/tests/folder-settings-transfer.test.mjs +++ b/tests/folder-settings-transfer.test.mjs @@ -62,7 +62,8 @@ test('folder settings transfer copies only safe settings payload fields', () => icon: 'folder.png', settings: { preview: 2, - override_default_actions: true + override_default_actions: true, + webui_profiles: [{ id: 'media', name: 'Media', containers: ['one'] }] }, actions: [ { type: 1, name: 'Script action', script: 'echo ok' }, @@ -77,6 +78,7 @@ test('folder settings transfer copies only safe settings payload fields', () => assert.deepEqual(Object.keys(entry.payload).sort(), ['actions', 'icon', 'settings']); assert.equal(entry.payload.icon, 'folder.png'); assert.equal(entry.payload.settings.preview, 2); + assert.equal(entry.payload.settings.webui_profiles, undefined); assert.equal(entry.payload.actions.length, 1); assert.equal(entry.payload.actions[0].name, 'Script action'); assert.deepEqual(Array.from(entry.meta.omittedTopLevelKeys), ['name', 'parentId', 'regex', 'containers', 'id', 'createdAt', 'updatedAt']); diff --git a/tests/folder-webui-profiles.test.mjs b/tests/folder-webui-profiles.test.mjs new file mode 100644 index 00000000..a9bfe03e --- /dev/null +++ b/tests/folder-webui-profiles.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const profiles = require('../src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.webui-profiles.js'); + +test('normalizes profile identities, names, and selected members', () => { + const normalized = profiles.normalizeProfiles([ + { id: 'media', name: ' Media ', containers: ['Plex', 'Plex', '', 'Tautulli'] }, + { id: 'media', name: '', members: ['Overseerr'] }, + null + ]); + assert.deepEqual(normalized, [ + { id: 'media', name: 'Media', containers: ['Plex', 'Tautulli'] }, + { id: 'media-2', name: 'Profile 2', containers: ['Overseerr'] } + ]); +}); + +test('resolves only currently eligible selected WebUIs and deduplicates URLs', () => { + const result = profiles.collectProfileTargets({ + id: 'media', name: 'Media', containers: ['Plex', 'Tautulli', 'Missing', 'Stopped'] + }, { + Plex: { name: 'Plex', state: true, pause: false, webui: 'http://plex/' }, + Tautulli: { state: true, pause: false, WebUi: 'http://plex/' }, + Stopped: { state: false, pause: false, webui: 'http://stopped/' }, + Other: { state: true, pause: false, webui: 'http://other/' } + }, { + getSafeWebuiUrl: (value) => /^https?:/.test(value) ? value : '', + runningOnly: true + }); + assert.deepEqual(result.urls, ['http://plex/']); + assert.equal(result.selectedCount, 4); + assert.equal(result.eligibleCount, 1); + assert.equal(result.unavailableCount, 1); +}); + +test('renames profile targets and strips profiles from transferable settings', () => { + assert.deepEqual(profiles.renameProfileMember([ + { id: 'media', name: 'Media', containers: ['Plex', 'Tautulli'] } + ], 'Plex', 'Plex-Media'), [ + { id: 'media', name: 'Media', containers: ['Plex-Media', 'Tautulli'] } + ]); + assert.deepEqual(profiles.stripProfilesFromSettings({ + preview: 2, + webui_profiles: [{ id: 'media' }], + webuiProfiles: [{ id: 'legacy' }] + }), { preview: 2 }); +}); + +test('diagnostic summary contains counts only', () => { + assert.deepEqual(profiles.summarizeProfiles([ + { id: 'media', name: 'Media', containers: ['Plex', 'Missing'] } + ], ['Plex']), { + profileCount: 1, + selectedReferenceCount: 2, + unavailableReferenceCount: 1, + duplicateProfileIdCount: 0, + invalidProfileCount: 0 + }); +}); diff --git a/tests/folderviewplus-utils.test.mjs b/tests/folderviewplus-utils.test.mjs index e1f9de60..013006d9 100644 --- a/tests/folderviewplus-utils.test.mjs +++ b/tests/folderviewplus-utils.test.mjs @@ -202,6 +202,24 @@ test('normalizeFolderMap rejects prototype keys and unsafe identifiers', () => { assert.equal(utils.normalizeFolderId('bad/id'), ''); }); +test('normalizeFolderMap rejects non-string names and members without coercion', () => { + const nonCoercible = { toString: 0 }; + const normalized = utils.normalizeFolderMap({ + unsafe: { + name: nonCoercible, + containers: [] + }, + safe: { + name: ' Safe ', + containers: [' plex ', nonCoercible, 42, 'plex', 'sonarr'] + } + }); + + assert.deepEqual(Object.keys(normalized), ['safe']); + assert.equal(normalized.safe.name, 'Safe'); + assert.deepEqual(normalized.safe.containers, ['plex', 'sonarr']); +}); + test('normalizeFolderMembers is exported and normalizes arrays/objects', () => { assert.equal(typeof utils.normalizeFolderMembers, 'function'); assert.deepEqual( @@ -503,20 +521,29 @@ test('orderFoldersByPrefs keeps child folders nested after parent in sorted outp test('getFolderStatusColors normalizes and defaults values', () => { const defaults = utils.getFolderStatusColors({}); assert.deepEqual(defaults, { - started: '#ffffff', + started: '#55b72d', paused: '#b8860b', - stopped: '#ff4d4d' + stopped: '#ff4d4d', + text: '#ffffff' }); + assert.equal(utils.getFolderStatusColors({ status_color_started: '#ffffff' }).started, '#55b72d'); + assert.equal(utils.getFolderStatusColors({ + status_color_started: '#ffffff', + status_color_started_explicit: true + }).started, '#ffffff'); + const custom = utils.getFolderStatusColors({ status_color_started: '#AbC', status_color_paused: '#123456', - status_color_stopped: 'bad-value' + status_color_stopped: 'bad-value', + status_color_text: '#fed' }); assert.deepEqual(custom, { started: '#aabbcc', paused: '#123456', - stopped: '#ff4d4d' + stopped: '#ff4d4d', + text: '#ffeedd' }); }); @@ -931,7 +958,11 @@ test('buildImportDiffRows reports row-level changed fields', () => { test('export/import roundtrip smoke works for full payload', () => { const original = { - abc: { name: 'One', containers: ['x'] }, + abc: { + name: 'One', + containers: ['x'], + settings: { webui_profiles: [{ id: 'tools', name: 'Tools', containers: ['x'] }] } + }, def: { name: 'Two', containers: [] } }; const exported = utils.buildFullExportPayload({ @@ -945,6 +976,7 @@ test('export/import roundtrip smoke works for full payload', () => { assert.deepEqual(Object.keys(parsed.folders), ['abc', 'def']); assert.equal(parsed.folders.abc.name, 'One'); assert.deepEqual(parsed.folders.abc.containers, ['x']); + assert.deepEqual(parsed.folders.abc.settings.webui_profiles, original.abc.settings.webui_profiles); assert.equal(parsed.folders.def.name, 'Two'); assert.deepEqual(parsed.folders.def.containers, []); @@ -986,12 +1018,14 @@ test('getConflictReport detects multi-folder assignment conflicts', () => { assert.equal(plex.matchedFolderCount, 2); }); -test('normalizePrefs keeps pinned folders and hide-empty toggle', () => { +test('normalizePrefs keeps pinned, hidden, and hide-empty folder preferences', () => { const prefs = utils.normalizePrefs({ pinnedFolderIds: ['a', 'b', 'a', '', 'c'], + hiddenFolderIds: ['hidden-a', 'hidden-b', 'hidden-a', ''], hideEmptyFolders: true }); assert.deepEqual(prefs.pinnedFolderIds, ['a', 'b', 'c']); + assert.deepEqual(prefs.hiddenFolderIds, ['hidden-a', 'hidden-b']); assert.equal(prefs.hideEmptyFolders, true); }); diff --git a/tests/localization-roadmap.test.mjs b/tests/localization-roadmap.test.mjs index ef860eb0..1beb3001 100644 --- a/tests/localization-roadmap.test.mjs +++ b/tests/localization-roadmap.test.mjs @@ -75,7 +75,7 @@ test('catalog report exposes honest legacy and namespace coverage for every regi assert.ok(report.sourceMessageCount > 1900); assert.equal(report.namespaceCount, 10); assert.equal(report.extraction.candidateCount, 0); - assert.equal(report.extraction.autoBoundMessageCount, 1574); + assert.equal(report.extraction.autoBoundMessageCount, 1579); assert.equal(report.extraction.catalogMessageCount, report.sourceMessageCount); assert.equal(Object.keys(report.locales).length, 27); assert.equal(report.locales.en.coveragePercent, 100); diff --git a/tests/localization-surface-coverage.test.mjs b/tests/localization-surface-coverage.test.mjs index 060c7cba..e038ae03 100644 --- a/tests/localization-surface-coverage.test.mjs +++ b/tests/localization-surface-coverage.test.mjs @@ -19,7 +19,7 @@ test('every detected legacy UI phrase is represented by one stable generated key const english = messagesOnly(readJson(path.join(namespacesRoot, 'en/legacy-surface.json'))); const expected = Object.fromEntries([...surface.byPhrase.keys()].sort().map((phrase) => [surfaceTools.keyForPhrase(phrase), phrase])); - assert.equal(surface.byPhrase.size, 1574); + assert.equal(surface.byPhrase.size, 1579); assert.deepEqual(english, expected); assert.equal(new Set(Object.keys(english)).size, surface.byPhrase.size); }); @@ -49,8 +49,8 @@ test('the extraction report and runtime enforce zero-debt initial and dynamic co assert.equal(report['catalog-version'], '2026.08.10.1'); assert.equal(report['candidate-count'], 0); - assert.equal(report['auto-bound-message-count'], 1574); - assert.equal(report['catalog-message-count'], 2122); + assert.equal(report['auto-bound-message-count'], 1579); + assert.equal(report['catalog-message-count'], 2168); assert.match(runtime, /rebuildAutoPhraseIndex/); assert.match(runtime, /resolveAutoTranslation/); assert.match(runtime, /observeDynamicTranslations/); diff --git a/tests/member-identity-reconciliation.test.mjs b/tests/member-identity-reconciliation.test.mjs index 0795b43a..08028851 100644 --- a/tests/member-identity-reconciliation.test.mjs +++ b/tests/member-identity-reconciliation.test.mjs @@ -23,7 +23,10 @@ test('a uniquely identified renamed Docker member keeps folder state and action mountDestinations: ['/config', '/tv'] } }, - actions: [{ type: 0, containers: ['old-name'], conatiners: ['old-name'] }] + actions: [{ type: 0, containers: ['old-name'], conatiners: ['old-name'] }], + settings: { + webui_profiles: [{ id: 'media', name: 'Media tools', containers: ['old-name'] }] + } } }; const runtime = { @@ -41,6 +44,7 @@ test('a uniquely identified renamed Docker member keeps folder state and action assert.deepEqual(result.folders.media.hiddenPreviewMembers, ['new-name']); assert.deepEqual(result.folders.media.actions[0].containers, ['new-name']); assert.deepEqual(result.folders.media.actions[0].conatiners, ['new-name']); + assert.deepEqual(result.folders.media.settings.webui_profiles[0].containers, ['new-name']); assert.equal(result.diagnostics.renamed, 1); assert.equal(result.patches.media.renames['old-name'], 'new-name'); }); diff --git a/tests/package-trust-security.test.mjs b/tests/package-trust-security.test.mjs index 9962e28c..75f81936 100644 --- a/tests/package-trust-security.test.mjs +++ b/tests/package-trust-security.test.mjs @@ -29,7 +29,9 @@ test('manifest and sidecar bind the current core package with SHA-256', () => { }); test('stable release publishes pinned provenance and SBOM attestations', () => { - assert.match(workflow, /permissions:\s*\n\s*contents:\s*write\s*\n\s*id-token:\s*write\s*\n\s*attestations:\s*write/); + assert.match(workflow, /^permissions:\s*\n contents:\s*read\s*$/m); + const releaseJob = workflow.match(/^ release:\s*$([\s\S]*?)(?=^ [A-Za-z0-9_-]+:\s*$|(?![\s\S]))/m)?.[1] || ''; + assert.match(releaseJob, /permissions:\s*\n\s*contents:\s*write\s*\n\s*id-token:\s*write\s*\n\s*attestations:\s*write/); const attestUses = workflow.match(/uses:\s*actions\/attest@[0-9a-f]{40}\s+# v4\.2\.0/g) || []; assert.equal(attestUses.length, 2); assert.match(workflow, /Attest release archive provenance[\s\S]*subject-path:[\s\S]*archive[\s\S]*checksum/); diff --git a/tests/performance-optimizations.test.mjs b/tests/performance-optimizations.test.mjs index 7cc17f3b..5fd59220 100644 --- a/tests/performance-optimizations.test.mjs +++ b/tests/performance-optimizations.test.mjs @@ -400,7 +400,8 @@ test('docker preview popup runtime stays enabled behind lazy advanced-preview in assert.match(dockerJs, /initializeDockerTooltipOnDemand\(\$\(tooltip_trigger_element\), \(\) => \$\(tooltip_trigger_element\)\.tooltipster\(\{/); assert.match(dockerJs, /\}\), triggerMode === 'hover'\);/); assert.match(dockerJs, /if \(DOCKER_PREVIEW_POPUP_ENABLED !== true\) \{\s*return;\s*\}/); - assert.match(dockerJs, /if\(DOCKER_PREVIEW_POPUP_ENABLED && tooltip_trigger_element && tooltip_trigger_element\.length > 0\) \{/); + assert.match(dockerJs, /const bindAdvancedPreviewContext = \(\$contextTrigger, context = \{\}\) =>/); + assert.match(dockerJs, /if\(DOCKER_PREVIEW_POPUP_ENABLED && tooltipTarget\.length > 0\) \{/); }); test('docker first paint keeps a lightweight loading shell and enriches state payload fields', () => { diff --git a/tests/phase6-php-decomposition.test.mjs b/tests/phase6-php-decomposition.test.mjs index dea83e82..43c51797 100644 --- a/tests/phase6-php-decomposition.test.mjs +++ b/tests/phase6-php-decomposition.test.mjs @@ -40,7 +40,7 @@ test('Phase 6 preserves the complete public endpoint manifest', () => { }); test('facades load every contracted PHP module without retaining extracted implementations', () => { - assert.equal(architecture.serverModuleContracts.length, 29); + assert.equal(architecture.serverModuleContracts.length, 30); for (const contract of architecture.serverModuleContracts) { const loader = contract.loadedBy === 'server/lib.php' ? coreFacade @@ -56,7 +56,7 @@ test('facades load every contracted PHP module without retaining extracted imple test('decomposition preserves every historical public PHP function name', () => { const expected = { - 'server/lib.php': { count: 332, sha256: '3402a8afdb4a90878b5a5cc26284075606051f35819e2859df9bc23a2b8612c3' }, + 'server/lib.php': { count: 337, sha256: 'bfa36d3deb8611f25234f639c900b8a97d50c3567a11d3b6f1c70339f4d93d04' }, 'server/lib.diagnostics.php': { count: 74, sha256: 'a1d008eb74affc973be697a63837347f992ff932591d6aa630b2a4d022a24b1a' }, 'server/upload_custom_icon.php': { count: 56, sha256: '825b3fbb5288c76f83a4976a55967e8a9a205e5496581f3a0c842789083f530e' } }; diff --git a/tests/phase8-test-runner-decomposition.test.mjs b/tests/phase8-test-runner-decomposition.test.mjs index 602aa972..f726e308 100644 --- a/tests/phase8-test-runner-decomposition.test.mjs +++ b/tests/phase8-test-runner-decomposition.test.mjs @@ -20,8 +20,8 @@ test('Phase 8 runner contract preserves deterministic fixture intent inventory', cwd: rootDir, encoding: 'utf8' }); - assert.match(output, /43 ordered fixture cases/); - assert.equal(contract.intent.fixture.assertionCount, 557); + assert.match(output, /49 ordered fixture cases/); + assert.equal(contract.intent.fixture.assertionCount, 617); }); test('Phase 8 change classification covers every extracted runner family', async () => { diff --git a/tests/runtime-page-diagnostics.test.mjs b/tests/runtime-page-diagnostics.test.mjs index b7374f18..c141adaf 100644 --- a/tests/runtime-page-diagnostics.test.mjs +++ b/tests/runtime-page-diagnostics.test.mjs @@ -18,13 +18,24 @@ const createFixture = () => { const nodes = { 'tr': [{}, {}], '.folder, .folder-showcase-outer': [{}], + '.folder.fv-folder-hidden-revealed': [{}, {}], '.folder[expanded="true"], .folder-showcase-outer[expanded="true"]': [{}], '.folder-preview-wrapper, .folder-element-docker, .folder-element-vm': [{}, {}, {}], '.spinner, .fv-runtime-loading-row, [aria-busy="true"]': [], '.fa-spin, .fa-spinner, .fa-circle-o-notch': [{}], '.error, .invalid, [aria-invalid="true"]': [] }; - const host = { clientWidth: 100, scrollWidth: 120, querySelectorAll: (selector) => nodes[selector] || [] }; + const bodyAttributes = new Map([ + ['data-fvplus-docker-hidden-explicit', '2'], + ['data-fvplus-docker-hidden-effective', '3'], + ['data-fvplus-docker-hidden-reveal', 'true'] + ]); + const host = { + clientWidth: 100, + scrollWidth: 120, + querySelectorAll: (selector) => nodes[selector] || [], + getAttribute: (name) => bodyAttributes.get(name) || null + }; const document = { body: host, documentElement: { clientWidth: 1200, scrollWidth: 1200 }, @@ -55,6 +66,10 @@ test('runtime page diagnostics captures allowlisted aggregate state only', () => }); assert.equal(snapshot.state.visibleRows, 2); assert.equal(snapshot.state.spinningControls, 1); + assert.equal(snapshot.state.hiddenFolderSelections, 2); + assert.equal(snapshot.state.effectivelyHiddenFolders, 3); + assert.equal(snapshot.state.revealedHiddenFolderRows, 2); + assert.equal(snapshot.state.revealHiddenFolders, true); assert.equal(snapshot.state.horizontalOverflow, true); assert.equal(snapshot.viewport.widthBucket, '1025-1440'); const serialized = JSON.stringify(snapshot); diff --git a/tests/runtime-theme-token-guard.test.mjs b/tests/runtime-theme-token-guard.test.mjs index 8c42868c..dbe1c559 100644 --- a/tests/runtime-theme-token-guard.test.mjs +++ b/tests/runtime-theme-token-guard.test.mjs @@ -49,9 +49,9 @@ test('runtime css defines canonical fvplus status tokens and legacy graph aliase }); test('status state classes resolve through css variables instead of hardcoded runtime values', () => { - assert.match(dockerCss, /span\.folder-state\.fv-folder-state-started\s*\{[\s\S]*var\(--fvplus-folder-status-started,\s*var\(--fvplus-status-started\)\)/); + assert.match(dockerCss, /span\.folder-state\.fv-folder-state-started\s*\{[\s\S]*var\(--fvplus-folder-status-text,\s*#ffffff\)/); assert.match(dockerCss, /i\.folder-load-status\.started\s*\{[\s\S]*var\(--fvplus-folder-status-started,\s*var\(--fvplus-status-started\)\)/); - assert.match(vmCss, /span\.folder-state\.fv-folder-state-started\s*\{[\s\S]*var\(--fvplus-folder-status-started,\s*var\(--fvplus-status-started\)\)/); + assert.match(vmCss, /span\.folder-state\.fv-folder-state-started\s*\{[\s\S]*var\(--fvplus-folder-status-text,\s*#ffffff\)/); assert.match(vmCss, /i\.folder-load-status\.started\s*\{[\s\S]*var\(--fvplus-folder-status-started,\s*var\(--fvplus-status-started\)\)/); }); @@ -95,6 +95,8 @@ test('runtime context menus follow resolved dark and light theme tokens', () => test('runtime scripts avoid inline status color painting and use row-level css variable overrides', () => { assert.match(sharedRuntimeJs, /const FOLDER_STATUS_COLOR_STYLE_PROPS = Object\.freeze/); + assert.match(sharedRuntimeJs, /text:\s*'--fvplus-folder-status-text'/); + assert.match(sharedRuntimeJs, /text:\s*normalizeStatusHexColor\(source\.status_color_text,\s*DEFAULT_FOLDER_STATUS_COLORS\.text\)/); assert.match(sharedRuntimeJs, /const applyFolderStatusColorOverrides = \(\$folderRow, settings\) =>/); assert.match(dockerJs, /applyFolderStatusColorOverrides\(\$folderRow,\s*folder\.settings\)/); assert.match(vmJs, /const applyFolderStatusColorOverrides = typeof runtimeShared\.applyFolderStatusColorOverrides === 'function'/); @@ -104,6 +106,7 @@ test('runtime scripts avoid inline status color painting and use row-level css v assert.match(dashboardJs, /const resolveDashboardFolderStatusColors = \(settings\) => \{/); assert.match(dashboardJs, /started:\s*'var\(--fvplus-folder-status-started,\s*var\(--fvplus-status-started,\s*var\(--fvplus-theme-foreground,\s*currentColor\)\)\)'/); assert.match(dashboardJs, /const statusColors = resolveDashboardFolderStatusColors\(folder\.settings\);/); + assert.match(dashboardJs, /\$statusText\.text\([^;]+\)\.css\('color',\s*statusColors\.text\)/); }); test('theme-change observers trigger deterministic reflow across runtime and settings surfaces', () => { @@ -253,6 +256,7 @@ test('runtime and settings overlays resolve through theme tokens instead of hard assert.match(dockerCss, /\.fv-preview-status-started\s*\{[^}]*var\(--fvplus-folder-status-started,\s*var\(--fvplus-status-started\)\)/); assert.match(dockerCss, /\.fv-preview-status-paused\s*\{[^}]*var\(--fvplus-folder-status-paused,\s*var\(--fvplus-status-paused\)\)/); assert.match(dockerCss, /\.fv-preview-status-stopped\s*\{[^}]*var\(--fvplus-folder-status-stopped,\s*var\(--fvplus-status-stopped\)\)/); + assert.match(dockerCss, /\.folder-preview \.fv-preview-status-compact \.state,[\s\S]*var\(--fvplus-folder-status-text,\s*#ffffff\)/); assert.match(cspUtilitiesCss, /\.fv-popup-guide\s*\{[^}]*color:\s*var\(--fvplus-runtime-menu-fg,\s*var\(--fvplus-theme-foreground,\s*currentColor\)\)/); assert.match(cspUtilitiesCss, /\.fv-popup-panel\s*\{[^}]*background:\s*var\(--fvplus-runtime-menu-header-bg,\s*transparent\)/); assert.doesNotMatch(dockerRuntimeActionsJs, /color:\s*#e8edf7/); diff --git a/tests/security-property-fuzz.test.mjs b/tests/security-property-fuzz.test.mjs new file mode 100644 index 00000000..2cf6e131 --- /dev/null +++ b/tests/security-property-fuzz.test.mjs @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import fc from 'fast-check'; +import { + compareSanitizedSupportBundles, + renderSupportBundleComparisonMarkdown +} from '../scripts/lib/support_bundle_compare.mjs'; + +const require = createRequire(import.meta.url); +const imageFallbacks = require('../src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/runtime.image-fallbacks.js'); +globalThis.FolderViewPlusFoundationModules = { imageFallbacks }; +const utils = require('../src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folderviewplus.utils.js'); +const containerModel = require('../src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/docker.runtime.container-model.js'); +const hierarchy = require('../src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/scripts/folder.editor.hierarchy.js').createApi(); + +const FUZZ_OPTIONS = Object.freeze({ numRuns: 250 }); + +const folderRecordArbitrary = fc.record({ + name: fc.oneof(fc.string({ maxLength: 80 }), fc.jsonValue()), + icon: fc.oneof(fc.string({ maxLength: 9000 }), fc.jsonValue()), + parentId: fc.oneof(fc.string({ maxLength: 80 }), fc.jsonValue()), + containers: fc.oneof( + fc.array(fc.jsonValue(), { maxLength: 40 }), + fc.dictionary(fc.string({ maxLength: 40 }), fc.boolean(), { maxKeys: 40 }), + fc.jsonValue() + ), + settings: fc.jsonValue(), + actions: fc.oneof(fc.array(fc.jsonValue(), { maxLength: 240 }), fc.jsonValue()) +}); + +test('fast-check: folder normalization preserves identifier and collection safety invariants', () => { + fc.assert(fc.property( + fc.dictionary(fc.string({ maxLength: 80 }), folderRecordArbitrary, { maxKeys: 50 }), + (source) => { + const normalized = utils.normalizeFolderMap(source); + assert.equal(Object.getPrototypeOf(normalized), Object.prototype); + for (const [id, folder] of Object.entries(normalized)) { + assert.equal(utils.normalizeFolderId(id), id); + assert.notEqual(id, '__proto__'); + assert.notEqual(id, 'constructor'); + assert.equal(folder.name, folder.name.trim()); + assert.notEqual(folder.name, ''); + assert.ok(Array.isArray(folder.containers)); + assert.deepEqual(folder.containers, [...new Set(folder.containers)]); + assert.ok(folder.containers.every((value) => typeof value === 'string' && value.trim() !== '')); + assert.ok(Array.isArray(folder.actions)); + assert.ok(folder.actions.length <= 200); + assert.equal(typeof folder.settings, 'object'); + assert.equal(Array.isArray(folder.settings), false); + } + } + ), FUZZ_OPTIONS); +}); + +test('fast-check: import parsing is total for arbitrary JSON and returns a bounded contract', () => { + fc.assert(fc.property( + fc.jsonValue(), + fc.constantFrom('docker', 'vm'), + (payload, expectedType) => { + const parsed = utils.parseImportPayload(payload, expectedType); + assert.equal(typeof parsed, 'object'); + assert.equal(typeof parsed.ok, 'boolean'); + if (parsed.ok) { + assert.equal(typeof parsed.folders, 'object'); + assert.equal(Array.isArray(parsed.folders), false); + assert.ok(Object.keys(parsed.folders).every((id) => utils.normalizeFolderId(id) === id)); + } else { + assert.equal(typeof parsed.error, 'string'); + assert.notEqual(parsed.error.trim(), ''); + } + } + ), FUZZ_OPTIONS); +}); + +test('fast-check: cyclic folder graphs produce bounded descendant sets', () => { + fc.assert(fc.property( + fc.array(fc.integer({ min: -1, max: 40 }), { minLength: 1, maxLength: 40 }), + fc.nat(), + (parentIndexes, rootSeed) => { + const ids = parentIndexes.map((_value, index) => `folder-${index}`); + const folders = Object.fromEntries(ids.map((id, index) => { + const parentIndex = parentIndexes[index]; + const parentId = parentIndex >= 0 ? ids[parentIndex % ids.length] : ''; + return [id, { name: id, parentId }]; + })); + const rootId = ids[rootSeed % ids.length]; + const descendants = hierarchy.computeFolderDescendantIds(folders, rootId); + assert.ok(descendants instanceof Set); + assert.equal(descendants.has(rootId), false); + assert.ok(descendants.size <= ids.length - 1); + assert.ok([...descendants].every((id) => Object.hasOwn(folders, id))); + } + ), FUZZ_OPTIONS); +}); + +test('fast-check: Docker container normalization returns immutable bounded structures', () => { + fc.assert(fc.property( + fc.dictionary(fc.string({ maxLength: 40 }), fc.jsonValue(), { maxKeys: 50 }), + fc.string({ maxLength: 40 }), + (entry, source) => { + const normalized = containerModel.normalizeContainer(entry, { source }); + assert.equal(normalized.schemaVersion, 1); + assert.ok(Object.isFrozen(normalized)); + assert.ok(Object.isFrozen(normalized.names)); + assert.ok(Object.isFrozen(normalized.ports)); + assert.ok(Object.isFrozen(normalized.mounts)); + assert.equal(normalized.name.startsWith('/'), false); + assert.ok(normalized.ports.every((port) => Object.isFrozen(port))); + assert.ok(normalized.mounts.every((mount) => Object.isFrozen(mount))); + } + ), FUZZ_OPTIONS); +}); + +test('fast-check: support-bundle comparison omits values behind sensitive keys', () => { + fc.assert(fc.property( + fc.stringMatching(/^[a-zA-Z0-9]{1,32}$/), + fc.nat({ max: 10000 }), + fc.nat({ max: 10000 }), + (suffix, leftCount, rightCount) => { + const marker = `private-${suffix}`; + const makeBundle = (folderCount) => ({ + bundleMeta: { privacyMode: 'sanitized', schemaVersion: 2 }, + pluginState: { + folderCount, + folderName: marker, + configPath: marker, + sourceUrl: marker, + ipAddress: marker, + accessToken: marker, + sessionCookie: marker, + machineUuid: marker + }, + redactionManifest: { mode: 'sanitized', hashedCount: 8 } + }); + const report = compareSanitizedSupportBundles(makeBundle(leftCount), makeBundle(rightCount)); + const rendered = renderSupportBundleComparisonMarkdown(report); + assert.doesNotMatch(JSON.stringify(report), new RegExp(marker)); + assert.doesNotMatch(rendered, new RegExp(marker)); + assert.ok(report.differences.every((row) => !/(name|path|url|ip|token|cookie|uuid)/i.test(row.path))); + } + ), FUZZ_OPTIONS); +}); diff --git a/tests/security-release-contract.test.mjs b/tests/security-release-contract.test.mjs index 380d222d..ab880ccc 100644 --- a/tests/security-release-contract.test.mjs +++ b/tests/security-release-contract.test.mjs @@ -16,6 +16,52 @@ test('CodeQL scans dev and main for pushes and pull requests', () => { assert.match(workflow, /workflow_dispatch:/); assert.match(workflow, /queries: security-extended,security-and-quality/); assert.equal((workflow.match(/github\/codeql-action\/(?:init|autobuild|analyze)@[0-9a-f]{40}\s+# v4/g) || []).length, 3); + assert.match(workflow, /^permissions:\s*\n actions:\s*read\s*\n contents:\s*read\s*$/m); + const analyzeJob = workflow.match(/^ analyze:\s*$([\s\S]*?)(?=^ [A-Za-z0-9_-]+:\s*$|(?![\s\S]))/m)?.[1] || ''; + assert.match(analyzeJob, /permissions:\s*\n\s*actions:\s*read\s*\n\s*contents:\s*read\s*\n\s*security-events:\s*write/); +}); + +test('write-capable workflows keep top-level permissions read-only and scope writes to jobs', () => { + const contracts = [ + { + file: 'backmerge-main-to-dev.yml', + job: 'backmerge', + permissionPattern: /permissions:\s*\n\s*contents:\s*write\s*\n\s*pull-requests:\s*write/ + }, + { + file: 'release-on-main.yml', + job: 'release', + permissionPattern: /permissions:\s*\n\s*contents:\s*write\s*\n\s*id-token:\s*write\s*\n\s*attestations:\s*write/ + }, + { + file: 'clone-traffic-badge.yml', + job: 'publish', + permissionPattern: /permissions:\s*\n\s*contents:\s*write/ + } + ]; + for (const contract of contracts) { + const workflow = read(path.join('.github', 'workflows', contract.file)); + assert.match(workflow, /^permissions:\s*\n contents:\s*read\s*$/m, contract.file); + const job = workflow.match(new RegExp(`^ ${contract.job}:\\s*$([\\s\\S]*?)(?=^ [A-Za-z0-9_-]+:\\s*$|(?![\\s\\S]))`, 'm'))?.[1] || ''; + assert.match(job, contract.permissionPattern, contract.file); + } +}); + +test('clone traffic credential is isolated from metrics branch publication', () => { + const workflow = read('.github/workflows/clone-traffic-badge.yml'); + const collectJob = workflow.match(/^ collect:\s*$([\s\S]*?)(?=^ [A-Za-z0-9_-]+:\s*$|(?![\s\S]))/m)?.[1] || ''; + const publishJob = workflow.match(/^ publish:\s*$([\s\S]*?)(?=^ [A-Za-z0-9_-]+:\s*$|(?![\s\S]))/m)?.[1] || ''; + assert.match(collectJob, /secrets\.FVPLUS_TRAFFIC_TOKEN/); + assert.doesNotMatch(collectJob, /github\.token/); + assert.match(publishJob, /github\.token/); + assert.doesNotMatch(publishJob, /secrets\.FVPLUS_TRAFFIC_TOKEN/); + assert.match(publishJob, /needs:\s*collect/); +}); + +test('security policy links directly to private vulnerability reporting', () => { + const policy = read('.github/SECURITY.md'); + const policyLines = new Set(policy.split(/\r?\n/)); + assert.ok(policyLines.has('[Report a vulnerability](https://github.com/alexphillips-dev/FolderView-Plus/security/advisories/new):')); }); test('dependency review blocks vulnerable or unapproved dependency changes', () => { diff --git a/tests/settings-bindings.test.mjs b/tests/settings-bindings.test.mjs index 2bf4b9c8..9a438daa 100644 --- a/tests/settings-bindings.test.mjs +++ b/tests/settings-bindings.test.mjs @@ -459,7 +459,8 @@ test('empty-state actions are delegated and create-folder uses modal input inste assert.match(script, /STARTER_TEMPLATE_BLUEPRINTS = Object\.freeze/); assert.match(script, /settings:\s*\{[\s\S]*preview:\s*1/); assert.match(script, /preview_border:\s*true/); - assert.match(script, /status_color_started:\s*'#ffffff'/); + assert.match(script, /status_color_started:\s*'#55b72d'/); + assert.match(script, /status_color_text:\s*'#ffffff'/); assert.match(script, /dashboard_overflow:\s*'default'/); assert.doesNotMatch(script, /window\.prompt\('Folder name:'/); }); diff --git a/tests/support-bundle-browser-telemetry.test.mjs b/tests/support-bundle-browser-telemetry.test.mjs index e3b98e48..274c0969 100644 --- a/tests/support-bundle-browser-telemetry.test.mjs +++ b/tests/support-bundle-browser-telemetry.test.mjs @@ -823,6 +823,16 @@ test('support bundle telemetry exports privacy-safe persisted preview context br counters: { handlerIntegrityFailures: 0, dispatchAttempts: 2, dispatchSuccesses: 2 }, rowModes: { '2': { bindings: 6 }, unlimited: { bindings: 6 } }, rowIndexes: { '1': { bound: 6 }, '2': { bound: 6 } }, + childFolderPreview: { + counters: { chipsRendered: 1, bindings: 1, menuOpenAttempts: 3, menuOpens: 3, menuOpenFailures: 0 }, + inputMethods: { mouse: 1, keyboard: 1, contextmenu: 1, touch: 0, unknown: 0 }, + lastEvent: { + type: 'menu-open', + outcome: 'success', + inputMethod: 'contextmenu', + folderName: 'private-folder' + } + }, lastEvent: { type: 'dispatch', outcome: 'success', @@ -847,6 +857,9 @@ test('support bundle telemetry exports privacy-safe persisted preview context br assert.equal(evidence.lastEvent.rowIndex, 2); assert.equal(evidence.lastEvent.triggerSource, 'status'); assert.equal(evidence.lastEvent.inputMethod, 'keyboard'); + assert.equal(evidence.childFolderPreview.counters.menuOpens, 3); + assert.equal(evidence.childFolderPreview.inputMethods.contextmenu, 1); + assert.match(evidence.childFolderPreview.lastEvent.folderName, /^ui-[0-9a-f]{16}$/); assert.match(evidence.lastEvent.containerName, /^ui-[0-9a-f]{16}$/); - assert.doesNotMatch(JSON.stringify(evidence), /private-container/); + assert.doesNotMatch(JSON.stringify(evidence), /private-container|private-folder/); }); diff --git a/tests/support-bundle-v2-contract.test.mjs b/tests/support-bundle-v2-contract.test.mjs index 3efe2bbc..f0258897 100644 --- a/tests/support-bundle-v2-contract.test.mjs +++ b/tests/support-bundle-v2-contract.test.mjs @@ -23,7 +23,8 @@ const FVPLUS_API_ERROR_LOG = ${phpSingleQuote(apiErrorLogPath)}; const FVPLUS_DEFAULT_FOLDER_STATUS_COLORS = [ 'started' => '#00ff00', 'paused' => '#f5c100', - 'stopped' => '#ff4d4f' + 'stopped' => '#ff4d4f', + 'text' => '#ffffff' ]; function readInstalledManifestPathCandidates(): array { diff --git a/tests/webui-profile-server-contract.test.mjs b/tests/webui-profile-server-contract.test.mjs new file mode 100644 index 00000000..c49f3d5f --- /dev/null +++ b/tests/webui-profile-server-contract.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; + +const phpPath = path.resolve('src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server/lib.webui-profiles.php'); +const phpQuote = (value) => `'${String(value).replaceAll('\\', '/').replaceAll("'", "\\'")}'`; +const runPhp = (expression) => JSON.parse(execFileSync('php', ['-r', + `require ${phpQuote(phpPath)}; echo json_encode(${expression}, JSON_UNESCAPED_SLASHES);` +], { encoding: 'utf8' })); + +test('server normalizes WebUI profiles and enforces unique identities and names', () => { + const result = runPhp(`fvplusNormalizeWebuiProfiles([ + ['id'=>'media','name'=>'Media','containers'=>['Plex','Plex','Tautulli']], + ['id'=>'media','name'=>'Media','members'=>['Overseerr']] + ])`); + assert.deepEqual(result, [ + { id: 'media', name: 'Media', containers: ['Plex', 'Tautulli'] }, + { id: 'media-2', name: 'Media 2', containers: ['Overseerr'] } + ]); +}); + +test('server diagnostics expose aggregate counts without profile or member values', () => { + const result = runPhp(`fvplusBuildWebuiProfileDiagnostics([ + 'webui_profiles'=>[['id'=>'media','name'=>'Media','containers'=>['Plex','Missing']]] + ], ['Plex'])`); + assert.deepEqual(result, { + profileCount: 1, + selectedReferenceCount: 2, + unavailableReferenceCount: 1, + duplicateProfileIdCount: 0, + invalidProfileCount: 0 + }); + assert.doesNotMatch(JSON.stringify(result), /Plex|Missing|Media/); +}); + +test('generic settings transfer strips member-bound WebUI profiles', () => { + const result = runPhp(`fvplusStripWebuiProfilesFromSettings([ + 'preview'=>2, + 'webui_profiles'=>[['id'=>'media']], + 'webuiProfiles'=>[['id'=>'legacy']] + ])`); + assert.deepEqual(result, { preview: 2 }); +});