diff --git a/.eleventyignore b/.eleventyignore
index 2052e7698..621be6d48 100644
--- a/.eleventyignore
+++ b/.eleventyignore
@@ -20,3 +20,4 @@ mockup/tests
mockup/node_modules/bootstrap/docs/
mockup/node_modules/bootstrap/grunt/
mockup/node_modules/bootstrap/js/tests/
+src/pat/filemanager/pat-filemanager-spec.md
diff --git a/.gitignore b/.gitignore
index 9f270eb2f..ceb7de586 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
!devsrc/.gitkeep
+.claude/
.env
.vscode
/docs/external/pat-*
@@ -15,3 +16,4 @@ docs/mockup/patterns
node_modules/
stats.json
/_site/*
+/stamp-yarn
diff --git a/babel.config.js b/babel.config.js
index 7f5303267..2c813f395 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -1 +1,15 @@
-module.exports = require("@patternslib/dev/babel.config.js");
+const base = require("@patternslib/dev/babel.config.js");
+
+// Extend the Patternslib base babel config with TypeScript support.
+// preset-typescript only acts on .ts/.tsx files (by extension), so plain .js
+// is unaffected. This covers plain .ts modules in webpack and all .ts files
+// under babel-jest. Runes-in-module files (.svelte.ts) are handled separately
+// in webpack/jest, not here.
+module.exports = (api) => {
+ const config = base(api);
+ config.presets = [
+ ...(config.presets || []),
+ ["@babel/preset-typescript", { allowDeclareFields: true }],
+ ];
+ return config;
+};
diff --git a/claude.md b/claude.md
new file mode 100644
index 000000000..c5a55b06c
--- /dev/null
+++ b/claude.md
@@ -0,0 +1,5 @@
+# important rules
+
+- use svelte 5 runes and $state/$derived patterns, not stores!
+- use this for testing: http://localhost:8080/Plone12/ use admin:admin
+- use svelte animations
diff --git a/jest.config.js b/jest.config.js
index 12f18ea25..293eadd7a 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -8,8 +8,16 @@ config.transformIgnorePatterns = [
"/node_modules/.pnpm/(?!@patternslib)(?!@plone)(?!preact)(?!screenfull)(?!sinon)(?!bootstrap)(?!datatable)(?!svelte)(?!esm-env)",
];
-// add svelte-jester
-config.transform["^.+\\.svelte$"] = "svelte-jester";
+// Transforms. Order matters: Jest uses the first matching pattern, so the
+// runes-in-module rule (.svelte.ts / .svelte.js) must precede the generic
+// babel rule (which would otherwise also match `.svelte.ts`).
+config.transform = {
+ "^.+\\.svelte\\.(js|ts)$": path.resolve(__dirname, "./tools/jest-svelte-module.cjs"),
+ // svelte-jester refuses to run in Jest's CJS mode, so use a custom client
+ // compile + ESM->CJS transformer (see the tool for the rationale).
+ "^.+\\.svelte$": path.resolve(__dirname, "./tools/jest-svelte-component.cjs"),
+ ...config.transform,
+};
// console.log(JSON.stringify(config, null, 4));
module.exports = config;
diff --git a/package.json b/package.json
index 6e21fb8a2..dbaae746f 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
"@11ty/eleventy": "^3.1.5",
"@11ty/eleventy-navigation": "^1.0.5",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
+ "@babel/preset-typescript": "^7.29.7",
"@patternslib/dev": "^4.0.0",
"@testing-library/jest-dom": "^6.9.1",
"@types/sinon": "^10.0.20",
@@ -60,8 +61,10 @@
"svelte": "^5.55.7",
"svelte-jester": "^5.0.0",
"svelte-loader": "^3.2.4",
+ "svelte-preprocess": "^6.0.5",
"svelte-scrollto": "^0.2.0",
- "svg-inline-loader": "^0.8.2"
+ "svg-inline-loader": "^0.8.2",
+ "typescript": "^6.0.3"
},
"resolutions": {
"@patternslib/patternslib": "9.10.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9e66cd460..aa03d3c41 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -128,9 +128,12 @@ importers:
'@11ty/eleventy-plugin-syntaxhighlight':
specifier: ^5.0.2
version: 5.0.2
+ '@babel/preset-typescript':
+ specifier: ^7.29.7
+ version: 7.29.7(@babel/core@7.29.0)
'@patternslib/dev':
specifier: ^4.0.0
- version: 4.0.0(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(jiti@2.7.0)(postcss@8.5.14)(tslib@2.8.1)(typescript@5.9.3)
+ version: 4.0.0(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(jiti@2.7.0)(postcss@8.5.14)(tslib@2.8.1)(typescript@6.0.3)
'@testing-library/jest-dom':
specifier: ^6.9.1
version: 6.9.1
@@ -158,12 +161,18 @@ importers:
svelte-loader:
specifier: ^3.2.4
version: 3.2.4(svelte@5.55.7(@typescript-eslint/types@8.57.2))
+ svelte-preprocess:
+ specifier: ^6.0.5
+ version: 6.0.5(@babel/core@7.29.0)(postcss@8.5.14)(sass@1.77.8)(svelte@5.55.7(@typescript-eslint/types@8.57.2))(typescript@6.0.3)
svelte-scrollto:
specifier: ^0.2.0
version: 0.2.0
svg-inline-loader:
specifier: ^0.8.2
version: 0.8.2
+ typescript:
+ specifier: ^6.0.3
+ version: 6.0.3
packages:
@@ -223,6 +232,10 @@ packages:
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/compat-data@7.29.3':
resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==}
engines: {node: '>=6.9.0'}
@@ -235,10 +248,18 @@ packages:
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
engines: {node: '>=6.9.0'}
+ '@babel/generator@7.29.7':
+ resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-annotate-as-pure@7.27.3':
resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-annotate-as-pure@7.29.7':
+ resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-compilation-targets@7.28.6':
resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==}
engines: {node: '>=6.9.0'}
@@ -249,6 +270,12 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0
+ '@babel/helper-create-class-features-plugin@7.29.7':
+ resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
'@babel/helper-create-regexp-features-plugin@7.28.5':
resolution: {integrity: sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==}
engines: {node: '>=6.9.0'}
@@ -264,28 +291,54 @@ packages:
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-member-expression-to-functions@7.28.5':
resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-member-expression-to-functions@7.29.7':
+ resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-module-imports@7.28.6':
resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-module-transforms@7.28.6':
resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
+ '@babel/helper-module-transforms@7.29.7':
+ resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
'@babel/helper-optimise-call-expression@7.27.1':
resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-optimise-call-expression@7.29.7':
+ resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-plugin-utils@7.28.6':
resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-plugin-utils@7.29.7':
+ resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-remap-async-to-generator@7.27.1':
resolution: {integrity: sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==}
engines: {node: '>=6.9.0'}
@@ -298,22 +351,44 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0
+ '@babel/helper-replace-supers@7.29.7':
+ resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-skip-transparent-expression-wrappers@7.29.7':
+ resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-validator-identifier@7.28.5':
resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-validator-option@7.27.1':
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
+ '@babel/helper-validator-option@7.29.7':
+ resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/helper-wrap-function@7.28.6':
resolution: {integrity: sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==}
engines: {node: '>=6.9.0'}
@@ -327,6 +402,11 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ '@babel/parser@7.29.7':
+ resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
'@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5':
resolution: {integrity: sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==}
engines: {node: '>=6.9.0'}
@@ -418,6 +498,12 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/plugin-syntax-jsx@7.29.7':
+ resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
'@babel/plugin-syntax-logical-assignment-operators@7.10.4':
resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==}
peerDependencies:
@@ -466,6 +552,12 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/plugin-syntax-typescript@7.29.7':
+ resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
'@babel/plugin-syntax-unicode-sets-regex@7.18.6':
resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==}
engines: {node: '>=6.9.0'}
@@ -622,6 +714,12 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/plugin-transform-modules-commonjs@7.29.7':
+ resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
'@babel/plugin-transform-modules-systemjs@7.29.4':
resolution: {integrity: sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==}
engines: {node: '>=6.9.0'}
@@ -754,6 +852,12 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/plugin-transform-typescript@7.29.7':
+ resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
'@babel/plugin-transform-unicode-escapes@7.27.1':
resolution: {integrity: sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==}
engines: {node: '>=6.9.0'}
@@ -789,18 +893,36 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0
+ '@babel/preset-typescript@7.29.7':
+ resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
'@babel/traverse@7.29.0':
resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==}
engines: {node: '>=6.9.0'}
+ '@babel/traverse@7.29.7':
+ resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==}
+ engines: {node: '>=6.9.0'}
+
'@babel/types@7.29.0':
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
+ '@babel/types@7.29.7':
+ resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
+ engines: {node: '>=6.9.0'}
+
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
@@ -5131,6 +5253,43 @@ packages:
peerDependencies:
svelte: ^3.0.0 || ^4.0.0-next.0 || ^5.0.0-next.1
+ svelte-preprocess@6.0.5:
+ resolution: {integrity: sha512-sgwew5yV/2eMeQobIWgAxCNarKwiTUDIc3siAUbq3sp0G6ONtzk0W+wJihMdqjbYb3iGU3ubpGv0usnnuXT3qg==}
+ engines: {node: '>= 18.0.0'}
+ peerDependencies:
+ '@babel/core': ^7.10.2
+ coffeescript: ^2.5.1
+ less: ^3.11.3 || ^4.0.0
+ postcss: ^7 || ^8
+ postcss-load-config: '>=3'
+ pug: ^3.0.0
+ sass: ~1.77.8
+ stylus: '>=0.55'
+ sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0
+ svelte: ^4.0.0 || ^5.0.0-next.100 || ^5.0.0
+ typescript: ^5.0.0 || ^6.0.0
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+ coffeescript:
+ optional: true
+ less:
+ optional: true
+ postcss:
+ optional: true
+ postcss-load-config:
+ optional: true
+ pug:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ typescript:
+ optional: true
+
svelte-scrollto@0.2.0:
resolution: {integrity: sha512-l4K2E4jr6dXCODtZDCkpp/TzknVVoq8gecHM0UOOmihvLosczdyLuyDUhZ+U2HkhLWi7nnimuOstZSFKtXSpmA==}
@@ -5344,8 +5503,8 @@ packages:
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
- typescript@5.9.3:
- resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ typescript@6.0.3:
+ resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
engines: {node: '>=14.17'}
hasBin: true
@@ -5804,6 +5963,12 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
'@babel/compat-data@7.29.3': {}
'@babel/core@7.29.0':
@@ -5834,10 +5999,22 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
+ '@babel/generator@7.29.7':
+ dependencies:
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
'@babel/types': 7.29.0
+ '@babel/helper-annotate-as-pure@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
'@babel/helper-compilation-targets@7.28.6':
dependencies:
'@babel/compat-data': 7.29.3
@@ -5859,6 +6036,19 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-annotate-as-pure': 7.29.7
+ '@babel/helper-member-expression-to-functions': 7.29.7
+ '@babel/helper-optimise-call-expression': 7.29.7
+ '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.0)
+ '@babel/helper-skip-transparent-expression-wrappers': 7.29.7
+ '@babel/traverse': 7.29.7
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/helper-create-regexp-features-plugin@7.28.5(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -5879,6 +6069,8 @@ snapshots:
'@babel/helper-globals@7.28.0': {}
+ '@babel/helper-globals@7.29.7': {}
+
'@babel/helper-member-expression-to-functions@7.28.5':
dependencies:
'@babel/traverse': 7.29.0
@@ -5886,6 +6078,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/helper-member-expression-to-functions@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/helper-module-imports@7.28.6':
dependencies:
'@babel/traverse': 7.29.0
@@ -5893,6 +6092,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -5902,12 +6108,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+ '@babel/traverse': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/helper-optimise-call-expression@7.27.1':
dependencies:
'@babel/types': 7.29.0
+ '@babel/helper-optimise-call-expression@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
'@babel/helper-plugin-utils@7.28.6': {}
+ '@babel/helper-plugin-utils@7.29.7': {}
+
'@babel/helper-remap-async-to-generator@7.27.1(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -5926,6 +6147,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-member-expression-to-functions': 7.29.7
+ '@babel/helper-optimise-call-expression': 7.29.7
+ '@babel/traverse': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
'@babel/traverse': 7.29.0
@@ -5933,12 +6163,25 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/helper-skip-transparent-expression-wrappers@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/helper-string-parser@7.27.1': {}
+ '@babel/helper-string-parser@7.29.7': {}
+
'@babel/helper-validator-identifier@7.28.5': {}
+ '@babel/helper-validator-identifier@7.29.7': {}
+
'@babel/helper-validator-option@7.27.1': {}
+ '@babel/helper-validator-option@7.29.7': {}
+
'@babel/helper-wrap-function@7.28.6':
dependencies:
'@babel/template': 7.28.6
@@ -5956,6 +6199,10 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
+ '@babel/parser@7.29.7':
+ dependencies:
+ '@babel/types': 7.29.7
+
'@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -6048,6 +6295,11 @@ snapshots:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
+
'@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -6093,6 +6345,11 @@ snapshots:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
+
'@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -6267,6 +6524,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.0)
+ '@babel/helper-plugin-utils': 7.29.7
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/plugin-transform-modules-systemjs@7.29.4(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -6409,6 +6674,17 @@ snapshots:
'@babel/core': 7.29.0
'@babel/helper-plugin-utils': 7.28.6
+ '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-annotate-as-pure': 7.29.7
+ '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.0)
+ '@babel/helper-plugin-utils': 7.29.7
+ '@babel/helper-skip-transparent-expression-wrappers': 7.29.7
+ '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.0)
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/plugin-transform-unicode-escapes@7.27.1(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
@@ -6516,12 +6792,29 @@ snapshots:
'@babel/types': 7.29.0
esutils: 2.0.3
+ '@babel/preset-typescript@7.29.7(@babel/core@7.29.0)':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/helper-plugin-utils': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.0)
+ '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.0)
+ '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.0)
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
+
'@babel/traverse@7.29.0':
dependencies:
'@babel/code-frame': 7.29.0
@@ -6534,18 +6827,35 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/traverse@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.7
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.7
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
'@babel/types@7.29.0':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ '@babel/types@7.29.7':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
'@bcoe/v8-coverage@0.2.3': {}
- '@commitlint/cli@20.5.3(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)':
+ '@commitlint/cli@20.5.3(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3)':
dependencies:
'@commitlint/format': 20.5.0
'@commitlint/lint': 20.5.3
- '@commitlint/load': 20.5.3(@types/node@25.8.0)(typescript@5.9.3)
+ '@commitlint/load': 20.5.3(@types/node@25.8.0)(typescript@6.0.3)
'@commitlint/read': 20.5.0(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)
'@commitlint/types': 20.5.0
tinyexec: 1.1.2
@@ -6590,14 +6900,14 @@ snapshots:
'@commitlint/rules': 20.5.3
'@commitlint/types': 20.5.0
- '@commitlint/load@20.5.3(@types/node@25.8.0)(typescript@5.9.3)':
+ '@commitlint/load@20.5.3(@types/node@25.8.0)(typescript@6.0.3)':
dependencies:
'@commitlint/config-validator': 20.5.0
'@commitlint/execute-rule': 20.0.0
'@commitlint/resolve-extends': 20.5.3
'@commitlint/types': 20.5.0
- cosmiconfig: 9.0.1(typescript@5.9.3)
- cosmiconfig-typescript-loader: 6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
+ cosmiconfig: 9.0.1(typescript@6.0.3)
+ cosmiconfig-typescript-loader: 6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3)
es-toolkit: 1.46.1
is-plain-obj: 4.1.0
picocolors: 1.1.1
@@ -7403,11 +7713,11 @@ snapshots:
colorette: 2.0.20
ora: 5.4.1
- '@patternslib/dev@4.0.0(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(jiti@2.7.0)(postcss@8.5.14)(tslib@2.8.1)(typescript@5.9.3)':
+ '@patternslib/dev@4.0.0(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(jiti@2.7.0)(postcss@8.5.14)(tslib@2.8.1)(typescript@6.0.3)':
dependencies:
'@babel/core': 7.29.0
'@babel/preset-env': 7.29.5(@babel/core@7.29.0)
- '@commitlint/cli': 20.5.3(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@5.9.3)
+ '@commitlint/cli': 20.5.3(@types/node@25.8.0)(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(typescript@6.0.3)
'@commitlint/config-conventional': 20.5.3
'@eslint/js': 10.0.1(eslint@10.4.0(jiti@2.7.0))
'@release-it/conventional-changelog': 10.0.6(conventional-commits-filter@5.0.0)(conventional-commits-parser@6.4.0)(release-it@20.0.1(@types/node@25.8.0))
@@ -8629,21 +8939,21 @@ snapshots:
core-util-is@1.0.3: {}
- cosmiconfig-typescript-loader@6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
+ cosmiconfig-typescript-loader@6.3.0(@types/node@25.8.0)(cosmiconfig@9.0.1(typescript@6.0.3))(typescript@6.0.3):
dependencies:
'@types/node': 25.8.0
- cosmiconfig: 9.0.1(typescript@5.9.3)
+ cosmiconfig: 9.0.1(typescript@6.0.3)
jiti: 2.6.1
- typescript: 5.9.3
+ typescript: 6.0.3
- cosmiconfig@9.0.1(typescript@5.9.3):
+ cosmiconfig@9.0.1(typescript@6.0.3):
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
js-yaml: 4.1.1
parse-json: 5.2.0
optionalDependencies:
- typescript: 5.9.3
+ typescript: 6.0.3
cross-spawn@7.0.6:
dependencies:
@@ -11402,6 +11712,15 @@ snapshots:
svelte-dev-helper: 1.1.9
svelte-hmr: 0.14.12(svelte@5.55.7(@typescript-eslint/types@8.57.2))
+ svelte-preprocess@6.0.5(@babel/core@7.29.0)(postcss@8.5.14)(sass@1.77.8)(svelte@5.55.7(@typescript-eslint/types@8.57.2))(typescript@6.0.3):
+ dependencies:
+ svelte: 5.55.7(@typescript-eslint/types@8.57.2)
+ optionalDependencies:
+ '@babel/core': 7.29.0
+ postcss: 8.5.14
+ sass: 1.77.8
+ typescript: 6.0.3
+
svelte-scrollto@0.2.0:
dependencies:
svelte: 3.59.2
@@ -11571,7 +11890,7 @@ snapshots:
typedarray@0.0.6: {}
- typescript@5.9.3: {}
+ typescript@6.0.3: {}
uc.micro@2.1.0: {}
diff --git a/src/pat/filemanager/README.md b/src/pat/filemanager/README.md
new file mode 100644
index 000000000..d513f8c46
--- /dev/null
+++ b/src/pat/filemanager/README.md
@@ -0,0 +1,251 @@
+---
+permalink: "pat/filemanager/"
+title: Filemanager
+---
+
+# Filemanager
+
+A folder-contents management UI — a modern, Backbone-free reimplementation of
+`pat-structure`, built on Svelte 5 runes and talking only to
+[plone.restapi](https://6.docs.plone.org/plone.restapi/docs/index.html).
+
+It renders a batched, sortable listing of a folder's contents — switchable
+between a **table** view and a photo-organizing **grid** view — with selection,
+clipboard (cut/copy/paste), delete, drag-and-drop ordering, drag-into-folder,
+multi-upload (including dropping files directly onto a subfolder to upload into
+it, or dropping a whole **folder** to recreate it — see *Folder drop* below),
+in-app folder browsing (breadcrumbs), column configuration,
+free-text/type filtering, advanced querystring filtering (build complex
+`plone.app.querystring` criteria like pat-structure), and batch actions
+(workflow, tags, properties, rename). The view choice is persisted per user in
+a cookie.
+
+## How it works
+
+The pattern mounts a Svelte app onto its trigger element. State lives in
+rune-based store classes (`.svelte.ts`) provided to components via
+`setContext`. Everything is discovered through restapi — `@querystring-search`
+(listing + server-side sort), `@querystring`, `@breadcrumbs`, `@types`,
+`@vocabularies`, `@workflow`, `@copy`/`@move`, `@tus-upload` and content
+`PATCH`/`DELETE` — so the pattern only needs a context URL and (ideally) the
+portal URL to work. There are **no** custom Plone JSON views and **no**
+add-content menu (adding content is out of scope).
+
+Sorting a column re-queries the server, so it orders the **whole** result set
+before batching — not just the visible page (the core fix over the legacy
+DataTables sort). Date columns sort on the catalog date index, so they sort as
+real dates.
+
+## Configuration
+
+Options are passed as a JSON object in the `data-pat-filemanager` attribute,
+using **camelCase** keys. All are optional except that a usable `contextUrl` is
+required (it defaults to the current page URL with a trailing
+`folder_contents` view stripped).
+
+| Option | Type | Default | Description |
+| :------------------: | :-----: | :--------------------------------: | :-------------------------------------------------------------------------------------------: |
+| contextUrl | string | current page URL (folder) | restapi URL of the folder to list. A trailing `/folder_contents` is stripped automatically. |
+| portalUrl | string | contextUrl | Portal root URL. Needed to derive portal-relative paths for the toolbar sync and breadcrumbs. |
+| contextPath | string | pathname of contextUrl | Portal-relative path of the context. |
+| activeColumns | array | image, Title, review_state, ModificationDate | Column keys shown by default (see column keys below). Persisted per user in localStorage. |
+| availableColumns | array | all column keys | Column keys offered in the column-configuration popover. |
+| portalTypes | array | [] (all types) | Restrict the listing to these `portal_type`s when no type filter is active. |
+| searchIndex | string | "SearchableText" | Catalog index used by the free-text filter. |
+| defaultBatchSize | integer | 25 | Initial page size (`b_size`). Selectable at runtime: 10/25/50/100. |
+| sortOn | string | "getObjPositionInParent" | Initial sort index. Manual ordering (drag/move-top/bottom) is enabled only for this value. |
+| sortOrder | string | "ascending" | Initial sort order: `"ascending"` or `"descending"`. |
+| defaultView | string | "table" | Initial listing view: `"table"` or `"grid"`. Switchable at runtime; persisted per user in a cookie. |
+| folderType | string | "Folder" | Portal type created for folders recreated from an OS folder drop (see *Folder drop* below). |
+
+### Column keys
+
+`activeColumns` / `availableColumns` accept these keys:
+
+| Key | Label | Type | Sortable |
+| --------------- | ------- | ----- | :------: |
+| image | Preview | image | no |
+| Title | Title | title | yes |
+| portal_type | Type | text | yes |
+| review_state | State | state | yes |
+| ModificationDate| Modified| date | yes |
+| CreationDate | Created | date | yes |
+| EffectiveDate | Published | date| yes |
+| ExpirationDate | Expires | date | yes |
+| Subject | Tags | tags | no |
+| getObjSize | Size | text | no |
+
+## Accessibility & keyboard navigation
+
+The pattern doesn't impose a custom grid-traversal model. The table is plain
+semantic HTML (`
`/``/``), so screen-reader semantics and the
+native `Tab` order work out of the box: within each row you tab through the
+select checkbox, the cell links, and the row-action menu. Focused ARIA widget
+patterns are layered only where they're needed — the row-action menu and the
+batch-action modal — and a shared `dismiss` action gives every popover the same
+`Escape` / outside-click behavior.
+
+### Popover dismissal — `src/utils/dismiss.ts`
+
+A reusable Svelte action attached to the wrapper that holds both the toggle and
+the popover (so clicking the toggle counts as "inside"):
+
+- `Escape` closes the popover (and stops propagation so it doesn't reach outer
+ handlers).
+- A `pointerdown` outside the wrapper closes it.
+- Listeners are bound on `document` only while the popover is open, so the many
+ closed row menus (one per row) cost nothing.
+
+Used by the row-action menu, the column-config popover, and the type-filter
+popover.
+
+### Row-action menu — `RowActionMenu.svelte` (ARIA menu pattern)
+
+- Toggle button carries `aria-haspopup="true"`, `aria-expanded`, and a
+ descriptive `aria-label` (*Actions for {title}*). The popover is `role="menu"`
+ with `role="menuitem"` children.
+- Opening the menu moves focus to the first **enabled** item.
+- `↑` / `↓` rove focus (wrapping at the ends), `Home` / `End` jump to first /
+ last. Disabled items (e.g. reorder actions when not in manual-order mode) are
+ skipped.
+- `Escape` or an outside click closes the menu and **returns focus to the
+ toggle**, so keyboard users aren't dropped to the top of the page.
+
+### Batch-action modal — `BatchActionModal.svelte` (native ``)
+
+- A single native `` opened with `.showModal()`, so it **overlays** the
+ listing on a dimmed `::backdrop` and the rest of the page is inert while open.
+ It's labelled by the action title via `aria-label`.
+- The toolbar's State / Tags / Properties / Rename buttons **toggle** it: clicking
+ the open action closes the dialog, clicking another switches the form in place.
+ Each button reflects its state with `aria-pressed`.
+- The native dialog handles accessibility for us: it moves focus inside on open,
+ **traps** `Tab` within the dialog, restores focus to the trigger on close, and
+ closes on `Escape`. An `$effect` keyed on `modal.isOpen` calls
+ `.showModal()` / `.close()`; the `cancel` event is blocked while a batch
+ operation runs, and a backdrop click closes the dialog.
+- Opening animates with a short CSS keyframe (`filemanager-modal-in`).
+
+### Labels, live regions, and icons
+
+- Checkboxes have contextual labels (*Select all on this page*, *Select
+ {name}*); the empty actions header column is labelled *Actions*.
+- Sortable column headers are real ``s; sort direction is shown with
+ ▲/▼.
+- Status messages render in a `role="status"` + `aria-live="polite"` region, so
+ operation results are announced; each has a *Dismiss message* button.
+- Breadcrumbs use `` + ``.
+- The upload zone is a `role="region"` whose label announces the drop
+ affordance; each in-progress upload exposes a labelled ``.
+- Column-config, type-filter and advanced-filter popovers are `role="group"`
+ with labels; every query-builder control carries an `aria-label`.
+- Decorative icons are `aria-hidden="true"`; thumbnails carry `alt` text.
+
+### Views
+
+The toolbar offers a **Table** / **Grid** switch (the choice persists in a
+cookie). Both views share the same selection, drag (reorder + drag-into-folder),
+clipboard, filtering, pagination, upload, and batch actions — the shared drag
+logic lives in `ListInteractions`, driven by a [sortablejs](https://github.com/SortableJS/Sortable)
+`use:` action (`utils/sortable.ts`), so each view only differs in the rendered
+element. The grid is a photo-organizing view with larger
+previews; column configuration is table-only and hidden in grid mode. The grid
+is an ARIA `listbox` of `option` cards. Each card is a **single tab stop** — its
+checkbox and title link are removed from the tab order (`tabindex="-1"`), so
+**Tab** jumps from one card to the next, not into the controls within a card.
+With a card focused, **Space** toggles its selection (a second press deselects;
+Shift+Space extends a range) and **Enter** opens it (folders drill in-app, other
+items navigate to the object).
+
+### Row / card selection
+
+Rows and grid cards can be selected by clicking, in addition to their checkboxes:
+
+- **Click** selects just that item (replacing any existing selection).
+- **Ctrl/Cmd+click** toggles an item in or out of the selection (multi-select).
+- **Shift+click** selects the inclusive range from the last clicked item.
+- **Space** (grid, card focused) toggles the focused card (second press
+ deselects); **Shift+Space** extends a range; **Enter** opens it.
+
+Clicks on an item's links, buttons, checkbox, or the row-action menu keep their
+own behavior and never change the selection. The per-item and *Select all*
+checkboxes remain the keyboard/accessible path; click-selection is a
+mouse/pointer enhancement on top of them. Dragging an item that's part of a
+multi-selection moves the **whole** selection (into a folder, via drop).
+
+### Drag-and-drop keyboard alternatives
+
+Drag interactions are mouse/pointer enhancements; each has a keyboard path:
+
+- **Column reorder** — drag is mirrored by per-column *Move up* / *Move down*
+ buttons (with *Move {name} up/down* labels) in the column-config popover.
+- **Row reorder** — the row menu offers *Move up* / *Move down* (single step)
+ plus *Move to top* / *Move to bottom*, all enabled only when `sortOn` is
+ `getObjPositionInParent`. Single-step moves reorder within the visible page
+ (the same scope as drag), so they're disabled at the first/last row of the
+ page. Arbitrary cross-page placement still needs *Cut* → *Paste*.
+- **Move into a folder** — *Cut* the row(s) → browse into the target folder →
+ *Paste*.
+
+The grid view has no per-item menu, so its keyboard reorder / move-to-top-bottom
+and set-as-default-page paths are the table view's row menu — switch to the table
+for those. Cut/copy/paste/delete and the batch actions (workflow, tags,
+properties, rename) work identically in both views via the toolbar.
+
+### Folder drop
+
+Dropping a **folder** from the OS (onto the listing, a subfolder row, or the
+"up to parent" card) recreates the folder structure in Plone and uploads every
+file inside it, recursively. Because a deep folder can be a large, hard-to-undo
+import, the drop is first **calculated and previewed**: a dialog shows the
+folder count, file count, total size and the folder tree, and nothing is written
+until you approve it (Cancel discards the drop entirely). Plain file drops are
+unaffected — they upload immediately with no preview. The recreated containers
+use the `folderType` option (default `"Folder"`). Folders are read via the
+browser's `DataTransferItem.webkitGetAsEntry()` entries API; browsers without it
+fall back to flat-file uploads.
+
+All user-facing strings are routed through the patternslib i18n bridge
+(`src/utils/i18n.ts`, `widgets` domain).
+
+## Default
+
+```html
+
+```
+
+## Custom columns and initial sort
+
+```html
+
+```
+
+## Restrict to types
+
+```html
+
+```
+
+Note: this pattern compiles Svelte 5 components and runes-in-module
+(`.svelte.ts`) stores — see the repo's `webpack.config.js` and `jest.config.js`
+for the loader / transform setup.
diff --git a/src/pat/filemanager/filemanager.css b/src/pat/filemanager/filemanager.css
new file mode 100644
index 000000000..7e8c073b7
--- /dev/null
+++ b/src/pat/filemanager/filemanager.css
@@ -0,0 +1,1750 @@
+.pat-filemanager-app {
+ --filemanager-border: var(--bs-border-color, #dee2e6);
+ --filemanager-muted: var(--bs-secondary-color, #6c757d);
+ /* Blue marks the sortablejs drop placeholder while reordering (the gap where
+ the dragged item will land); green (drop-target) marks a move-into-folder
+ target. */
+ --filemanager-drop: var(--bs-primary, #0d6efd);
+ /* Fixed height shared by every toolbar element so they all align. */
+ --filemanager-action-h: 2rem;
+ /* Shared font size for toolbar, table headers and pagination — slightly
+ smaller than the table body so chrome recedes and content stands out. */
+ --filemanager-ui-size: 0.875rem;
+}
+
+body:has(.pat-filemanager) #portal-header {
+ display: none;
+}
+
+.pat-filemanager-app .filemanager-breadcrumbs ol {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ margin: 0 0 0.75rem;
+ padding: 0;
+ list-style: none;
+}
+
+.pat-filemanager-app .filemanager-breadcrumbs li:not(:last-child)::after {
+ content: "/";
+ margin-left: 0.25rem;
+ color: var(--filemanager-muted);
+}
+
+.pat-filemanager-app .filemanager-breadcrumbs li.active a {
+ font-weight: 600;
+ pointer-events: none;
+}
+
+.pat-filemanager-app .filemanager-stickybar {
+ position: sticky;
+ top: 0;
+ z-index: 30;
+ background: #fff;
+ padding-bottom: 0.25rem;
+ margin-bottom: 0.5rem;
+}
+
+/* Actions on the left, search + advanced filter pushed to the right, all on
+ one row with the batch-action buttons. */
+.pat-filemanager-app .filemanager-actionbar {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.5rem 0;
+ flex-wrap: wrap;
+ font-size: var(--filemanager-ui-size);
+}
+
+.pat-filemanager-app .filemanager-actionbar .filemanager-actions {
+ margin-bottom: 0;
+}
+
+.pat-filemanager-app .filemanager-filterbar {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 0 1 auto;
+ flex-wrap: wrap;
+ margin-left: auto;
+ justify-content: flex-end;
+}
+
+/* Search + Filter joined as one input group (like pat-structure). */
+.pat-filemanager-app .filemanager-search-group {
+ display: inline-flex;
+ align-items: stretch;
+}
+
+.pat-filemanager-app .filemanager-search {
+ height: var(--filemanager-action-h);
+ box-sizing: border-box;
+ padding: 0 0.5rem;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 6px;
+ min-width: 12rem;
+}
+
+/* When followed by the Filter button, drop the search's right edge so the two
+ read as a single control. */
+.pat-filemanager-app .filemanager-search-group:has(.filemanager-queryfilter) .filemanager-search {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ border-right: 0;
+}
+
+.pat-filemanager-app .filemanager-queryfilter-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font: inherit;
+ cursor: pointer;
+ height: var(--filemanager-action-h);
+ box-sizing: border-box;
+ padding: 0 0.7rem;
+ border: 1px solid var(--filemanager-border);
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ border-top-right-radius: 6px;
+ border-bottom-right-radius: 6px;
+ background: #fff;
+ color: inherit;
+ white-space: nowrap;
+}
+
+.pat-filemanager-app .filemanager-queryfilter-toggle:hover {
+ background: #f1f3f5;
+}
+
+.pat-filemanager-app .filemanager-queryfilter-toggle.has-filter {
+ font-weight: 600;
+}
+
+.pat-filemanager-app .filemanager-queryfilter,
+.pat-filemanager-app .filemanager-columns-config {
+ position: relative;
+}
+
+.pat-filemanager-app .filemanager-queryfilter-popover,
+.pat-filemanager-app .filemanager-columns-popover {
+ position: absolute;
+ z-index: 10;
+ top: calc(100% + 0.25rem);
+ left: 0;
+ min-width: 12rem;
+ padding: 0.5rem 0.75rem;
+ background: #fff;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 4px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
+}
+
+/* The advanced filter now lives at the right edge, so open its popover
+ right-aligned to keep it on-screen. */
+.pat-filemanager-app .filemanager-queryfilter-popover {
+ min-width: 24rem;
+ left: auto;
+ right: 0;
+}
+
+.pat-filemanager-app .filemanager-querybuilder-empty {
+ margin: 0 0 0.5rem;
+ color: var(--filemanager-muted);
+ font-size: 0.85rem;
+}
+
+.pat-filemanager-app .filemanager-querybuilder-row {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ margin-bottom: 0.4rem;
+ flex-wrap: wrap;
+}
+
+.pat-filemanager-app .filemanager-querybuilder-row select,
+.pat-filemanager-app .filemanager-querybuilder-row input {
+ padding: 0.2rem 0.35rem;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 3px;
+ font: inherit;
+}
+
+.pat-filemanager-app .filemanager-querybuilder-value {
+ flex: 1 1 8rem;
+ min-width: 6rem;
+}
+
+.pat-filemanager-app .filemanager-querybuilder-multi {
+ min-height: 4.5rem;
+}
+
+.pat-filemanager-app .filemanager-querybuilder-range,
+.pat-filemanager-app .filemanager-querybuilder-relativedate {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ flex: 1 1 auto;
+}
+
+.pat-filemanager-app .filemanager-querybuilder-remove {
+ border: 0;
+ background: none;
+ cursor: pointer;
+ font-size: 1.1rem;
+ line-height: 1;
+ color: var(--filemanager-muted);
+ padding: 0 0.25rem;
+}
+
+.pat-filemanager-app .filemanager-querybuilder-add {
+ margin-top: 0.25rem;
+ border: 1px solid var(--filemanager-border);
+ background: #fff;
+ cursor: pointer;
+ border-radius: 3px;
+ padding: 0.25rem 0.6rem;
+ font: inherit;
+}
+
+.pat-filemanager-app .filemanager-columns-config {
+ margin-left: auto;
+}
+
+.pat-filemanager-app .filemanager-columns-toggle {
+ border: 0;
+ background: none;
+ cursor: pointer;
+ font-size: 1.1rem;
+ line-height: 1;
+ padding: 0.1rem 0.35rem;
+}
+
+/* In the actions column header the popover must open toward the table, not
+ off the right edge — and shed the inherited bold / right alignment. */
+.pat-filemanager-app .filemanager-actions-col .filemanager-columns-popover {
+ left: auto;
+ right: 0;
+ font-weight: normal;
+ text-align: left;
+}
+
+.pat-filemanager-app .filemanager-viewswitcher {
+ display: inline-flex;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.pat-filemanager-app .filemanager-view-button {
+ display: inline-flex;
+ align-items: center;
+ border: 0;
+ background: #fff;
+ cursor: pointer;
+ height: var(--filemanager-action-h);
+ box-sizing: border-box;
+ padding: 0 0.55rem;
+ font: inherit;
+}
+
+.pat-filemanager-app .filemanager-view-button + .filemanager-view-button {
+ border-left: 1px solid var(--filemanager-border);
+}
+
+.pat-filemanager-app .filemanager-view-button.active {
+ background: #0d6efd;
+ color: #fff;
+}
+
+.pat-filemanager-app .filemanager-columns-heading {
+ margin: 0.25rem 0;
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--filemanager-muted);
+}
+
+.pat-filemanager-app .filemanager-columns-list {
+ margin: 0 0 0.5rem;
+ padding: 0;
+ list-style: none;
+}
+
+.pat-filemanager-app .filemanager-columns-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.15rem 0;
+ cursor: grab;
+}
+
+.pat-filemanager-app .filemanager-columns-item.dragging {
+ opacity: 0.5;
+}
+
+.pat-filemanager-app .filemanager-columns-reorder button {
+ display: inline-flex;
+ align-items: center;
+ font: inherit;
+ cursor: pointer;
+ padding: var(--bs-btn-sm-padding-y, 0.25rem) var(--bs-btn-sm-padding-x, 0.5rem);
+ font-size: 0.875rem;
+ border-radius: var(--bs-border-radius-sm, 0.25rem);
+ border: 1px solid var(--bs-secondary-border-subtle, var(--filemanager-border));
+ background: transparent;
+ color: var(--bs-secondary, #6c757d);
+}
+
+.pat-filemanager-app .filemanager-columns-reorder button:hover:not(:disabled) {
+ background: var(--bs-secondary, #6c757d);
+ border-color: var(--bs-secondary, #6c757d);
+ color: #fff;
+}
+
+.pat-filemanager-app .filemanager-columns-reorder button:disabled {
+ opacity: 0.3;
+ cursor: default;
+}
+
+.pat-filemanager-app .filemanager-columns-item label {
+ display: flex;
+ align-items: center;
+ gap: 0.35rem;
+ cursor: pointer;
+}
+
+.pat-filemanager-app .filemanager-columns-actions {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+ border-top: 1px solid var(--filemanager-border);
+ padding-top: 0.5rem;
+}
+
+.pat-filemanager-app .filemanager-columns-actions button {
+ display: inline-flex;
+ align-items: center;
+ font: inherit;
+ cursor: pointer;
+ padding: var(--bs-btn-sm-padding-y, 0.25rem) var(--bs-btn-sm-padding-x, 0.5rem);
+ font-size: 0.875rem;
+ border-radius: var(--bs-border-radius-sm, 0.25rem);
+ border: 1px solid var(--bs-border-color, var(--filemanager-border));
+ background: var(--bs-body-bg, #fff);
+ color: var(--bs-body-color, #212529);
+}
+
+.pat-filemanager-app .filemanager-columns-actions button:hover {
+ background: var(--bs-tertiary-bg, #f8f9fa);
+ border-color: var(--bs-secondary, #6c757d);
+}
+
+.pat-filemanager-app .filemanager-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+ flex-wrap: wrap;
+}
+
+/* Shared small-button look for every toolbar action (matches the segmented
+ ViewSwitcher / pat-structure button bar). */
+.pat-filemanager-app .filemanager-actions button {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ font: inherit;
+ cursor: pointer;
+ height: var(--filemanager-action-h);
+ box-sizing: border-box;
+ padding: 0 0.7rem;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 6px;
+ background: #fff;
+ color: inherit;
+}
+
+/* Plone icons resolved via the @@iconresolver, sized like pat-structure's
+ btn-sm icons and tinted with the button's colour (so Delete's icon is red). */
+.pat-filemanager-app .filemanager-icon {
+ display: inline-flex;
+ flex: none;
+ width: 1rem;
+ height: 1rem;
+}
+
+.pat-filemanager-app .filemanager-icon svg {
+ width: 1rem;
+ height: 1rem;
+ display: block;
+}
+
+/* Tint the icon (and any inner paths that ship a hardcoded fill) with the
+ button's colour — CSS fill beats the SVG's presentation attribute, so the
+ Delete action's red reaches its icon. */
+.pat-filemanager-app .filemanager-icon svg,
+.pat-filemanager-app .filemanager-icon svg * {
+ fill: currentColor;
+}
+
+/* The main actions are icon-only (label is the tooltip), so use tighter
+ horizontal padding than the text buttons. */
+.pat-filemanager-app .filemanager-action-group button {
+ padding: 0 0.55rem;
+}
+
+.pat-filemanager-app .filemanager-actions button:hover:not(:disabled) {
+ background: #f1f3f5;
+}
+
+.pat-filemanager-app .filemanager-actions button:disabled {
+ color: var(--filemanager-muted);
+ cursor: default;
+ opacity: 0.65;
+}
+
+
+/* The main actions render as one connected button group, like pat-structure's
+ mainbuttons: square inner corners, shared (collapsed) borders. */
+.pat-filemanager-app .filemanager-action-group {
+ display: inline-flex;
+}
+
+.pat-filemanager-app .filemanager-action-group button {
+ border-radius: 0;
+ border-left-width: 0;
+}
+
+.pat-filemanager-app .filemanager-action-group button:first-child {
+ border-top-left-radius: 6px;
+ border-bottom-left-radius: 6px;
+ border-left-width: 1px;
+}
+
+.pat-filemanager-app .filemanager-action-group button:last-child {
+ border-top-right-radius: 6px;
+ border-bottom-right-radius: 6px;
+}
+
+/* Scoped under .filemanager-actions so it wins over the generic
+ `.filemanager-actions button` rule (more specific than a lone class): a red
+ background with a white icon (the icon inherits #fff via fill: currentColor). */
+.pat-filemanager-app .filemanager-actions .filemanager-action-delete {
+ border-color: #b02a37;
+ background: #b02a37;
+ color: #fff;
+}
+
+.pat-filemanager-app .filemanager-actions .filemanager-action-delete:hover:not(:disabled) {
+ background: #9a2530;
+}
+
+/* Keep the icon white when disabled too (the shared :disabled rule otherwise
+ tints it muted grey); it just fades via the inherited opacity. */
+.pat-filemanager-app .filemanager-actions .filemanager-action-delete:disabled {
+ color: #fff;
+}
+
+.pat-filemanager-app .filemanager-actions button[aria-pressed="true"] {
+ background: #e7f1ff;
+ font-weight: 600;
+}
+
+.pat-filemanager-app .filemanager-action-selectall,
+.pat-filemanager-app .filemanager-allselected {
+ font-size: 0.85rem;
+}
+
+.pat-filemanager-app .filemanager-allselected {
+ color: var(--filemanager-muted);
+}
+
+.pat-filemanager-app .filemanager-uploadzone {
+ position: relative;
+}
+
+.pat-filemanager-app .filemanager-uploadzone.drag-active {
+ outline: 2px dashed #0d6efd;
+ outline-offset: -2px;
+}
+
+.pat-filemanager-app .filemanager-upload-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(13, 110, 253, 0.08);
+ font-weight: 600;
+ color: #0d6efd;
+ pointer-events: none;
+}
+
+.pat-filemanager-app .filemanager-upload {
+ position: relative;
+ padding: 0.4rem 1.75rem 0.4rem 0.6rem;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 4px;
+ background: #f8f9fa;
+}
+
+.pat-filemanager-app .filemanager-upload-summary {
+ margin: 0 0 0.4rem;
+ font-weight: 600;
+ color: #198754;
+}
+
+.pat-filemanager-app .filemanager-upload-summary.is-active {
+ color: var(--filemanager-muted);
+}
+
+.pat-filemanager-app .filemanager-upload-summary.is-error {
+ color: #b02a37;
+}
+
+.pat-filemanager-app .filemanager-upload-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.pat-filemanager-app .filemanager-upload-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.85rem;
+}
+
+.pat-filemanager-app .filemanager-upload-name {
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pat-filemanager-app .filemanager-upload-size {
+ color: var(--filemanager-muted);
+}
+
+.pat-filemanager-app .filemanager-upload-item.is-error .filemanager-upload-error {
+ color: #b02a37;
+}
+
+.pat-filemanager-app .filemanager-upload-item.is-done .filemanager-upload-done {
+ color: #198754;
+}
+
+.pat-filemanager-app .filemanager-upload-close {
+ position: absolute;
+ top: 0.25rem;
+ right: 0.25rem;
+ border: 0;
+ background: none;
+ cursor: pointer;
+ font-size: 1.1rem;
+ line-height: 1;
+ padding: 0 0.25rem;
+ color: var(--filemanager-muted);
+}
+
+.pat-filemanager-app .filemanager-progress {
+ padding: 0.4rem 0.6rem;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 4px;
+ background: #f8f9fa;
+}
+
+.pat-filemanager-app .filemanager-progress-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+}
+
+.pat-filemanager-app .filemanager-progress-item {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.85rem;
+}
+
+.pat-filemanager-app .filemanager-progress-label {
+ flex: 1 1 auto;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: var(--filemanager-muted);
+}
+
+.pat-filemanager-app .filemanager-progress-count {
+ color: var(--filemanager-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+/* Self-closing dialog shown while a copy/paste runs. A compact, near-square
+ card centred over a darker backdrop. */
+.filemanager-progress-dialog {
+ width: 16rem;
+ min-width: 16rem;
+ min-height: 12rem;
+}
+
+/* Only lay out the box while open; closed dialogs keep the UA display:none —
+ otherwise the empty dialog renders as a bordered box over the listing. */
+.pat-filemanager-app .filemanager-progress-dialog[open] {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.filemanager-progress-dialog::backdrop {
+ background: rgba(0, 0, 0, 0.6);
+}
+
+.filemanager-progress-dialog .filemanager-progress-list {
+ width: 100%;
+}
+
+.filemanager-progress-dialog .filemanager-progress-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.75rem;
+ font-size: 0.9rem;
+ text-align: center;
+}
+
+.filemanager-progress-dialog .filemanager-progress-label {
+ color: var(--filemanager-muted);
+}
+
+.filemanager-progress-dialog progress {
+ width: 100%;
+ height: 0.9rem;
+}
+
+/* Busy folder (drag-into-folder target while the @move runs). A dark green
+ overlay covers the whole item (row or card) so it's obvious which folder is
+ receiving the items and that work is in progress. */
+.pat-filemanager-app .filemanager-row.is-busy,
+.pat-filemanager-app .filemanager-card.is-busy {
+ pointer-events: none;
+}
+
+.pat-filemanager-app .filemanager-row.is-busy {
+ position: relative;
+}
+
+/* Overlay spans the whole row: the cell stays static so `inset: 0` resolves to
+ the position:relative row. */
+.pat-filemanager-app .filemanager-row-progress {
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ padding: 0 1rem;
+ background: rgba(15, 81, 50, 0.88);
+ border-radius: 4px;
+}
+
+.pat-filemanager-app .filemanager-row-progress-label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.pat-filemanager-app .filemanager-row-progress progress {
+ flex: 1 1 auto;
+ max-width: 16rem;
+ height: 0.9rem;
+ accent-color: #75b798;
+}
+
+/* A full-card overlay with the label and a chunky bar. */
+.pat-filemanager-app .filemanager-card-progress {
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem;
+ background: rgba(15, 81, 50, 0.88);
+ border: 2px solid #0f5132;
+ border-radius: 6px;
+}
+
+.pat-filemanager-app .filemanager-card-progress-label {
+ font-size: 0.8rem;
+ font-weight: 600;
+ line-height: 1.2;
+ text-align: center;
+ color: #fff;
+}
+
+.pat-filemanager-app .filemanager-card-progress progress {
+ width: 90%;
+ height: 0.9rem;
+ accent-color: #75b798;
+}
+
+/* Border + padding match the toolbar action buttons so the select-all control
+ reads as one of the actions rather than a bare checkbox + label. */
+.pat-filemanager-app .filemanager-grid-selectall {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ color: var(--filemanager-muted);
+ cursor: pointer;
+ font-size: 0.85rem;
+ height: var(--filemanager-action-h);
+ box-sizing: border-box;
+ padding: 0 0.7rem;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 6px;
+ background: #fff;
+}
+
+.pat-filemanager-app .filemanager-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
+ gap: 1rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+/* Five image-size stages driven by the grid size slider. Only the card's
+ minimum width changes; the cards keep their 4:3 preview and flow to fill. */
+.pat-filemanager-app .filemanager-grid.grid-size-xs {
+ grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
+}
+
+.pat-filemanager-app .filemanager-grid.grid-size-s {
+ grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
+}
+
+.pat-filemanager-app .filemanager-grid.grid-size-m {
+ grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
+}
+
+.pat-filemanager-app .filemanager-grid.grid-size-l {
+ grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
+}
+
+/* Largest stage: fixed 2-column layout. */
+.pat-filemanager-app .filemanager-grid.grid-size-xl {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+/* Image-size slider, shown left of search/filter while the grid view is
+ active. Sits inline with the search row and stays compact. */
+.pat-filemanager-app .filemanager-grid-size {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ color: var(--filemanager-muted);
+}
+
+.pat-filemanager-app .filemanager-grid-size input[type="range"] {
+ width: 6rem;
+ cursor: pointer;
+}
+
+.pat-filemanager-app .filemanager-grid-size-icon {
+ line-height: 1;
+ color: var(--filemanager-muted);
+}
+
+.pat-filemanager-app .filemanager-grid-size-small .filemanager-icon {
+ width: 0.85rem;
+ height: 0.85rem;
+}
+
+.pat-filemanager-app .filemanager-grid-size-small .filemanager-icon svg {
+ width: 0.85rem;
+ height: 0.85rem;
+}
+
+.pat-filemanager-app .filemanager-grid-size-large .filemanager-icon {
+ width: 1.4rem;
+ height: 1.4rem;
+}
+
+.pat-filemanager-app .filemanager-grid-size-large .filemanager-icon svg {
+ width: 1.4rem;
+ height: 1.4rem;
+}
+
+.pat-filemanager-app .filemanager-card {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ padding: 0.5rem;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 6px;
+ background: #fff;
+ cursor: grab;
+}
+
+.pat-filemanager-app .filemanager-card.is-selected {
+ background: #e7f1ff;
+ border-color: #9ec5fe;
+}
+
+.pat-filemanager-app .filemanager-card:hover:not(.is-selected) {
+ background: var(--bs-tertiary-bg, #f8f9fa);
+}
+
+.pat-filemanager-app .filemanager-card.is-cut {
+ opacity: 0.5;
+}
+
+/* The chosen card (the one being dragged) fades while sortablejs animates a
+ clone to the cursor. */
+.pat-filemanager-app .filemanager-card.dragging {
+ opacity: 0.4;
+}
+
+.pat-filemanager-app .filemanager-grid.can-reorder .filemanager-card {
+ cursor: grab;
+}
+
+/* sortablejs's drop placeholder: an accent-tinted outline marking the slot the
+ dragged card will land in. */
+.pat-filemanager-app .filemanager-card.filemanager-drag-ghost {
+ opacity: 1;
+ background: rgba(13, 110, 253, 0.08);
+ box-shadow: inset 0 0 0 2px var(--filemanager-drop);
+}
+
+.pat-filemanager-app .filemanager-card.drop-target {
+ background: #d1e7dd;
+ box-shadow: inset 0 0 0 2px #198754;
+}
+
+.pat-filemanager-app .filemanager-card:focus-visible {
+ outline: 2px solid #0d6efd;
+ outline-offset: 2px;
+}
+
+/* A check-circle / check-circle-fill icon stands in for the select checkbox.
+ The native input stays for accessibility and keyboard toggling but is hidden
+ behind the icon; a little inset keeps the icon clear of the card border. */
+.pat-filemanager-app .filemanager-card-select {
+ position: absolute;
+ top: 0.85rem;
+ left: 0.85rem;
+ z-index: 1;
+ margin: 0;
+ line-height: 0;
+ cursor: pointer;
+}
+
+.pat-filemanager-app .filemanager-card-select input {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ opacity: 0;
+ cursor: pointer;
+}
+
+.pat-filemanager-app .filemanager-card-select .filemanager-icon {
+ color: var(--filemanager-muted);
+ background: #fff;
+ border-radius: 50%;
+}
+
+.pat-filemanager-app .filemanager-card-select.is-checked .filemanager-icon {
+ color: #198754;
+}
+
+.pat-filemanager-app .filemanager-card-select input:focus-visible + .filemanager-icon {
+ outline: 2px solid #0d6efd;
+ outline-offset: 1px;
+ border-radius: 50%;
+}
+
+.pat-filemanager-app .filemanager-card-preview {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ aspect-ratio: 4 / 3;
+ background: #f1f3f5;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.pat-filemanager-app .filemanager-card-preview img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.pat-filemanager-app .filemanager-card-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.pat-filemanager-app .filemanager-card-icon .filemanager-icon,
+.pat-filemanager-app .filemanager-card-icon .filemanager-icon svg {
+ width: 3rem;
+ height: 3rem;
+}
+
+/* "Up to parent" placeholder card: a dashed, muted tile that browses up on
+ click and accepts drops (move/upload) into the parent container. It is not
+ draggable or selectable — just navigate + drop. */
+.pat-filemanager-app .filemanager-card-up {
+ border-style: dashed;
+ cursor: pointer;
+}
+
+.pat-filemanager-app .filemanager-card-up-link {
+ width: 100%;
+ text-decoration: none;
+ color: var(--filemanager-muted);
+ cursor: pointer;
+}
+
+/* "Up to parent" row in the table view — same intent, adapted for a . */
+.pat-filemanager-app .filemanager-row-up td {
+ border-bottom-style: dashed;
+}
+
+.pat-filemanager-app .filemanager-row-up-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ text-decoration: none;
+ color: var(--filemanager-muted);
+ cursor: pointer;
+}
+
+.pat-filemanager-app .filemanager-row-up-link:hover {
+ color: var(--bs-body-color, #212529);
+ text-decoration: underline;
+}
+
+.pat-filemanager-app .filemanager-card-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.9rem;
+ text-decoration: none;
+ color: inherit;
+}
+
+.pat-filemanager-app .filemanager-card-title:hover {
+ text-decoration: underline;
+}
+
+.pat-filemanager-app .filemanager-title {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ text-decoration: none;
+ color: inherit;
+}
+
+.pat-filemanager-app .filemanager-title:hover {
+ text-decoration: underline;
+}
+
+.pat-filemanager-app .filemanager-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.pat-filemanager-app .filemanager-row.is-selected {
+ background: #e7f1ff;
+}
+
+.pat-filemanager-app .filemanager-row:hover:not(.is-selected) > td {
+ background: var(--bs-tertiary-bg, #f8f9fa);
+}
+
+.pat-filemanager-app .filemanager-row.is-cut {
+ opacity: 0.5;
+}
+
+.pat-filemanager-app .filemanager-row.dragging {
+ opacity: 0.4;
+}
+
+/* sortablejs's drop placeholder for the table: an accent line across the row
+ marking the slot the dragged row will land in. */
+.pat-filemanager-app .filemanager-row.filemanager-drag-ghost > td {
+ background-image: linear-gradient(var(--filemanager-drop), var(--filemanager-drop));
+ background-repeat: no-repeat;
+ background-size: 100% 3px;
+ background-position: 0 0;
+}
+
+.pat-filemanager-app .filemanager-row.drop-target > td {
+ background: #d1e7dd;
+ border-top: 2px solid #198754;
+ border-bottom: 2px solid #198754;
+ border-left: none;
+ border-right: none;
+}
+
+.pat-filemanager-app .filemanager-row.drop-target > td:first-child {
+ border-left: 2px solid #198754;
+}
+
+.pat-filemanager-app .filemanager-row.drop-target > td:last-child {
+ border-right: 2px solid #198754;
+}
+
+.pat-filemanager-app .filemanager-table.can-reorder .filemanager-row {
+ cursor: grab;
+}
+
+.pat-filemanager-app .filemanager-actions-col {
+ width: 2rem;
+ text-align: right;
+}
+
+.pat-filemanager-app .filemanager-rowmenu {
+ position: relative;
+}
+
+.pat-filemanager-app .filemanager-rowmenu-toggle {
+ border: 0;
+ background: none;
+ cursor: pointer;
+ font-size: 1.1rem;
+ line-height: 1;
+ padding: 0.1rem 0.35rem;
+}
+
+.pat-filemanager-app .filemanager-rowmenu-popover {
+ position: absolute;
+ z-index: 10;
+ top: 100%;
+ right: 0;
+ display: flex;
+ flex-direction: column;
+ min-width: 11rem;
+ padding: 0.25rem;
+ background: #fff;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 4px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
+}
+
+.pat-filemanager-app .filemanager-rowmenu-popover > * {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: 0;
+ background: none;
+ padding: 0.3rem 0.5rem;
+ font: inherit;
+ color: inherit;
+ text-decoration: none;
+ cursor: pointer;
+ border-radius: 3px;
+}
+
+.pat-filemanager-app .filemanager-rowmenu-popover > *:hover {
+ background: #f1f3f5;
+}
+
+.pat-filemanager-app .filemanager-rowmenu-popover > *:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+.pat-filemanager-app .filemanager-table th,
+.pat-filemanager-app .filemanager-table td {
+ padding: 0.4rem 0.6rem;
+ border-bottom: 1px solid var(--filemanager-border);
+ text-align: left;
+ vertical-align: middle;
+}
+
+.pat-filemanager-app .filemanager-table th {
+ font-size: var(--filemanager-ui-size);
+}
+
+.pat-filemanager-app .filemanager-table .filemanager-select {
+ width: 1.5rem;
+}
+
+.pat-filemanager-app .filemanager-sort {
+ background: none;
+ border: 0;
+ padding: 0;
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.pat-filemanager-app .filemanager-sort.active {
+ color: #0d6efd;
+}
+
+/* Drag a column header onto another to reorder the columns. */
+.pat-filemanager-app .filemanager-header[draggable="true"] {
+ cursor: grab;
+}
+
+.pat-filemanager-app .filemanager-header.col-dragging {
+ opacity: 0.4;
+}
+
+/* Accent bar on the header the dragged column will drop onto. */
+.pat-filemanager-app .filemanager-header.col-drop-target {
+ box-shadow: inset 3px 0 0 var(--filemanager-drop);
+}
+
+.pat-filemanager-app .filemanager-thumb {
+ width: 2.5rem;
+ height: 2.5rem;
+ object-fit: cover;
+ border-radius: 2px;
+}
+
+.pat-filemanager-app .filemanager-thumb-placeholder {
+ display: inline-block;
+ width: 2.5rem;
+ height: 2.5rem;
+ background: #f1f3f5;
+ border-radius: 2px;
+}
+
+/* Loading skeletons: muted blocks that occupy the same space the loaded rows
+ and cards will, so swapping in the real content doesn't shift the layout. A
+ slow opacity pulse hints that work is in progress; disabled for users who
+ prefer reduced motion. Pure CSS, no JS. */
+.pat-filemanager-app .filemanager-skeleton {
+ background: #e9ecef;
+ border-radius: 4px;
+ animation: filemanager-skeleton-pulse 1.2s ease-in-out infinite;
+}
+
+@keyframes filemanager-skeleton-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .pat-filemanager-app .filemanager-skeleton {
+ animation: none;
+ }
+}
+
+/* Grid skeleton cards reuse the real card/preview boxes, so the grid reserves
+ the exact 4:3 tiles — just greyed out and non-interactive. */
+.pat-filemanager-app .filemanager-card-skeleton {
+ cursor: default;
+}
+
+.pat-filemanager-app .filemanager-card-skeleton .filemanager-card-title {
+ height: 0.9rem;
+ width: 70%;
+}
+
+/* Table skeleton bar fills the cell at text height (capped so it reads as a
+ placeholder rather than stretching across wide columns). */
+.pat-filemanager-app .filemanager-skeleton-bar {
+ display: block;
+ height: 0.9rem;
+ width: 100%;
+ max-width: 12rem;
+}
+
+.pat-filemanager-app .filemanager-state {
+ display: inline-block;
+ padding: 0.1rem 0.45rem;
+ border-radius: 0.75rem;
+ font-size: 0.8rem;
+ background: #e9ecef;
+}
+
+.pat-filemanager-app .filemanager-state.state-published {
+ background: #d1e7dd;
+}
+
+.pat-filemanager-app .filemanager-state.state-private {
+ background: #f8d7da;
+}
+
+.pat-filemanager-app .filemanager-tag {
+ display: inline-block;
+ margin-right: 0.25rem;
+ padding: 0.05rem 0.4rem;
+ border-radius: 0.25rem;
+ font-size: 0.8rem;
+ background: #e7f1ff;
+}
+
+/* Inline indicator in the title cell for exclude_from_nav */
+.pat-filemanager-app .filemanager-nav-excluded {
+ display: inline-flex;
+ align-items: center;
+ color: var(--bs-secondary-color, #6c757d);
+ opacity: 0.7;
+ margin-left: 0.2rem;
+}
+
+/* Grid card badge for boolean indicators */
+.pat-filemanager-app .filemanager-card-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.2rem;
+ font-size: var(--filemanager-ui-size, 0.875rem);
+ color: var(--bs-secondary-color, #6c757d);
+ padding: 0.1rem 0.35rem;
+ border-radius: var(--bs-border-radius-sm, 0.25rem);
+ background: var(--bs-secondary-bg-subtle, #e9ecef);
+}
+
+.pat-filemanager-app .filemanager-message {
+ padding: 1rem;
+ text-align: center;
+ color: var(--filemanager-muted);
+}
+
+.pat-filemanager-app .filemanager-message.filemanager-error {
+ color: #b02a37;
+}
+
+.pat-filemanager-app .filemanager-pagination {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-top: 0.75rem;
+ flex-wrap: wrap;
+ font-size: var(--filemanager-ui-size);
+}
+
+.pat-filemanager-app .filemanager-range {
+ color: var(--filemanager-muted);
+}
+
+.pat-filemanager-app .filemanager-pager {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+/* Icon-only prev/next buttons, matching the small-button look of the toolbar. */
+.pat-filemanager-app .filemanager-pager-button {
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+ padding: 0.25rem 0.45rem;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 6px;
+ background: #fff;
+ color: inherit;
+}
+
+.pat-filemanager-app .filemanager-pager-button:hover:not(:disabled) {
+ background: #f1f3f5;
+}
+
+.pat-filemanager-app .filemanager-pager-button:disabled {
+ color: var(--filemanager-muted);
+ cursor: default;
+ opacity: 0.65;
+}
+
+.pat-filemanager-app .filemanager-pager-button .filemanager-icon {
+ width: 1rem;
+ height: 1rem;
+}
+
+/* Batch-size choices render as a segmented button group like the ViewSwitcher
+ and pat-structure's paging "Show:" buttons, with the active size highlighted. */
+.pat-filemanager-app .filemanager-batchsize {
+ margin-left: auto;
+ display: inline-flex;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.pat-filemanager-app .filemanager-batchsize-button {
+ border: 0;
+ background: #fff;
+ cursor: pointer;
+ padding: 0.3rem 0.6rem;
+ font: inherit;
+}
+
+.pat-filemanager-app .filemanager-batchsize-button + .filemanager-batchsize-button {
+ border-left: 1px solid var(--filemanager-border);
+}
+
+.pat-filemanager-app .filemanager-batchsize-button:hover:not(:disabled):not(.active) {
+ background: #f1f3f5;
+}
+
+.pat-filemanager-app .filemanager-batchsize-button.active {
+ background: #0d6efd;
+ color: #fff;
+}
+
+.pat-filemanager-app .filemanager-batchsize-button:disabled {
+ cursor: default;
+}
+
+.pat-filemanager-app .filemanager-status {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ margin-bottom: 0.75rem;
+}
+
+.pat-filemanager-app .filemanager-status-message {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 0.5rem;
+ padding: 0.4rem 0.6rem;
+ border: 1px solid var(--filemanager-border);
+ border-radius: 4px;
+ background: #e9ecef;
+}
+
+.pat-filemanager-app .filemanager-status-message.is-success {
+ background: #d1e7dd;
+ border-color: #badbcc;
+}
+
+.pat-filemanager-app .filemanager-status-message.is-warning {
+ background: #fff3cd;
+ border-color: #ffecb5;
+}
+
+.pat-filemanager-app .filemanager-status-message.is-error {
+ background: #f8d7da;
+ border-color: #f5c2c7;
+}
+
+.pat-filemanager-app .filemanager-status-dismiss {
+ border: 0;
+ background: none;
+ cursor: pointer;
+ font-size: 1.1rem;
+ line-height: 1;
+ padding: 0 0.25rem;
+}
+
+.pat-filemanager-app .filemanager-modal {
+ width: min(640px, calc(100vw - 2rem));
+ max-height: calc(100vh - 4rem);
+ padding: 0;
+ border: 1px solid var(--bs-modal-border-color, var(--filemanager-border));
+ border-radius: var(--bs-modal-border-radius, var(--bs-border-radius-lg, 0.5rem));
+ background: var(--bs-modal-bg, #fff);
+ overflow: hidden;
+}
+
+/* Only lay out the box while open; closed dialogs keep the UA display:none. */
+.pat-filemanager-app .filemanager-modal[open] {
+ display: flex;
+ flex-direction: column;
+ animation: filemanager-modal-in 150ms ease-out;
+}
+
+.pat-filemanager-app .filemanager-modal::backdrop {
+ background: rgba(0, 0, 0, 0.4);
+}
+
+@keyframes filemanager-modal-in {
+ from {
+ opacity: 0;
+ transform: translateY(-8px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: none;
+ }
+}
+
+.pat-filemanager-app .filemanager-modal-header {
+ display: flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--bs-modal-header-padding, 1rem);
+ border-bottom: var(--bs-modal-header-border-width, 1px) solid var(--bs-modal-header-border-color, var(--filemanager-border));
+ background: var(--bs-tertiary-bg, #f8f9fa);
+}
+
+.pat-filemanager-app .filemanager-modal-header h2 {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 500;
+ line-height: var(--bs-modal-title-line-height, 1.5);
+}
+
+.pat-filemanager-app .filemanager-modal-close {
+ border: 0;
+ background: none;
+ cursor: pointer;
+ font-size: 1.4rem;
+ line-height: 1;
+ padding: 0 0.25rem;
+ opacity: 0.5;
+}
+
+.pat-filemanager-app .filemanager-modal-close:hover:not(:disabled) {
+ opacity: 0.75;
+}
+
+.pat-filemanager-app .filemanager-modal-form {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 0.75rem;
+ min-height: 0;
+ padding: var(--bs-modal-padding, 1rem);
+ overflow-y: auto;
+}
+
+.pat-filemanager-app .filemanager-modal-intro {
+ margin: 0;
+ color: var(--filemanager-muted);
+ font-size: 0.85rem;
+}
+
+.pat-filemanager-app .filemanager-modal-loading,
+.pat-filemanager-app .filemanager-modal-error {
+ margin: 0;
+}
+
+.pat-filemanager-app .filemanager-modal-error {
+ color: var(--bs-danger, #dc3545);
+}
+
+.pat-filemanager-app .filemanager-field,
+.pat-filemanager-app label.filemanager-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.pat-filemanager-app .filemanager-field > span,
+.pat-filemanager-app .filemanager-field > legend {
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.pat-filemanager-app .filemanager-field input[type="text"],
+.pat-filemanager-app .filemanager-field input[type="datetime-local"],
+.pat-filemanager-app .filemanager-field select,
+.pat-filemanager-app .filemanager-field textarea {
+ padding: var(--bs-form-control-padding-y, 0.375rem) var(--bs-form-control-padding-x, 0.75rem);
+ border: var(--bs-border-width, 1px) solid var(--bs-border-color, var(--filemanager-border));
+ border-radius: var(--bs-border-radius-sm, 0.25rem);
+ font: inherit;
+ background-color: var(--bs-body-bg, #fff);
+ color: var(--bs-body-color, inherit);
+ transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+}
+
+.pat-filemanager-app .filemanager-field input:focus,
+.pat-filemanager-app .filemanager-field select:focus,
+.pat-filemanager-app .filemanager-field textarea:focus {
+ border-color: var(--bs-primary, #86b7fe);
+ outline: 0;
+ box-shadow: 0 0 0 var(--bs-focus-ring-width, 0.25rem) var(--bs-focus-ring-color, rgba(13, 110, 253, 0.25));
+}
+
+.pat-filemanager-app label.filemanager-field-check,
+.pat-filemanager-app label.filemanager-field-radio {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.pat-filemanager-app .filemanager-rename-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ max-height: 24rem;
+ overflow-y: auto;
+}
+
+.pat-filemanager-app .filemanager-rename-row {
+ display: flex;
+ gap: 0.75rem;
+}
+
+.pat-filemanager-app .filemanager-rename-row .filemanager-field {
+ flex: 1 1 0;
+}
+
+.pat-filemanager-app .filemanager-modal-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ border-top: var(--bs-modal-footer-border-width, 1px) solid var(--bs-modal-footer-border-color, var(--filemanager-border));
+ padding-top: var(--bs-modal-padding, 0.75rem);
+}
+
+.pat-filemanager-app .filemanager-modal-actions button {
+ display: inline-flex;
+ align-items: center;
+ font: inherit;
+ cursor: pointer;
+ padding: var(--bs-btn-padding-y, 0.375rem) var(--bs-btn-padding-x, 0.75rem);
+ border-radius: var(--bs-btn-border-radius, 0.375rem);
+ border: 1px solid var(--bs-secondary-border-subtle, #ced4da);
+ background: var(--bs-secondary-bg-subtle, #e9ecef);
+ color: var(--bs-body-color, #212529);
+ white-space: nowrap;
+}
+
+.pat-filemanager-app .filemanager-modal-actions button:hover:not(:disabled) {
+ background: var(--bs-secondary-bg, #adb5bd);
+ border-color: var(--bs-secondary, #6c757d);
+ color: #fff;
+}
+
+.pat-filemanager-app .filemanager-modal-actions button:disabled {
+ opacity: var(--bs-btn-disabled-opacity, 0.65);
+ cursor: default;
+}
+
+.pat-filemanager-app .filemanager-modal-actions .filemanager-modal-submit {
+ border-color: var(--bs-primary, #0d6efd);
+ background: var(--bs-primary, #0d6efd);
+ color: var(--bs-white, #fff);
+ font-weight: 600;
+}
+
+.pat-filemanager-app .filemanager-modal-actions .filemanager-modal-submit:hover:not(:disabled) {
+ background: var(--bs-btn-hover-bg, #0b5ed7);
+ border-color: var(--bs-btn-hover-border-color, #0a58ca);
+ color: #fff;
+}
+
+.pat-filemanager-app .filemanager-modal-actions .filemanager-modal-submit.filemanager-action-delete {
+ border-color: var(--bs-danger, #dc3545);
+ background: var(--bs-danger, #dc3545);
+ color: #fff;
+}
+
+.pat-filemanager-app .filemanager-modal-actions .filemanager-modal-submit.filemanager-action-delete:hover:not(:disabled) {
+ background: #b02a37;
+ border-color: #a52834;
+ color: #fff;
+}
+
+/* Link integrity breach list */
+.pat-filemanager-app .filemanager-integrity-list {
+ list-style: none;
+ padding: 0;
+ margin: 0 0 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ max-height: 50vh;
+ overflow-y: auto;
+}
+
+.pat-filemanager-app .filemanager-integrity-item {
+ border: 1px solid var(--bs-border-color, #dee2e6);
+ border-radius: var(--bs-border-radius, 0.375rem);
+ padding: 0.5rem 0.75rem;
+}
+
+.pat-filemanager-app .filemanager-integrity-target {
+ font-weight: 600;
+ display: block;
+ margin-bottom: 0.25rem;
+}
+
+.pat-filemanager-app .filemanager-integrity-count {
+ display: block;
+ font-size: var(--filemanager-ui-size, 0.875rem);
+ color: var(--bs-secondary-color, #6c757d);
+ margin-bottom: 0.25rem;
+}
+
+.pat-filemanager-app .filemanager-integrity-label {
+ font-size: var(--filemanager-ui-size, 0.875rem);
+ color: var(--bs-secondary-color, #6c757d);
+ display: block;
+ margin-bottom: 0.25rem;
+}
+
+.pat-filemanager-app .filemanager-integrity-sources {
+ list-style: disc;
+ padding-left: 1.25rem;
+ margin: 0;
+}
+
+/* Confirmation dialog: a compact variant of the modal with its own padding
+ (the base modal sets padding:0 for full-bleed forms). */
+.pat-filemanager-app .filemanager-confirm {
+ width: min(420px, calc(100vw - 2rem));
+ padding: 1.25rem;
+ color: #212529;
+}
+
+.pat-filemanager-app .filemanager-confirm-message {
+ margin: 0 0 1rem;
+}
+
+.pat-filemanager-app .filemanager-confirm-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+}
+
+.pat-filemanager-app .filemanager-confirm-actions button {
+ display: inline-flex;
+ align-items: center;
+ font: inherit;
+ cursor: pointer;
+ padding: var(--bs-btn-padding-y, 0.375rem) var(--bs-btn-padding-x, 0.75rem);
+ border: 1px solid var(--bs-border-color, var(--filemanager-border));
+ border-radius: var(--bs-btn-border-radius, 0.375rem);
+ background: var(--bs-body-bg, #fff);
+ color: var(--bs-body-color, #212529);
+}
+
+.pat-filemanager-app .filemanager-confirm-actions button:hover {
+ background: var(--bs-tertiary-bg, #f8f9fa);
+}
+
+.pat-filemanager-app .filemanager-confirm-actions .filemanager-confirm-ok {
+ font-weight: 600;
+ border-color: var(--bs-primary, #0d6efd);
+ background: var(--bs-primary, #0d6efd);
+ color: var(--bs-white, #fff);
+}
+
+.pat-filemanager-app .filemanager-confirm-actions .filemanager-confirm-ok:hover {
+ background: var(--bs-btn-hover-bg, #0b5ed7);
+ border-color: var(--bs-btn-hover-border-color, #0a58ca);
+}
+
+.pat-filemanager-app .filemanager-confirm-actions .filemanager-confirm-ok.filemanager-action-delete {
+ border-color: var(--bs-danger, #dc3545);
+ background: var(--bs-danger, #dc3545);
+ color: var(--bs-white, #fff);
+}
+
+/* ── Folder-drop preview/approval dialog ─────────────────────────────────── */
+
+.pat-filemanager-app .filemanager-folderdrop {
+ width: min(520px, calc(100vw - 2rem));
+ padding: 1.25rem;
+ color: #212529;
+}
+
+.pat-filemanager-app .filemanager-folderdrop-title {
+ margin: 0 0 0.5rem;
+ font-size: 1.1rem;
+ font-weight: 600;
+}
+
+.pat-filemanager-app .filemanager-folderdrop-summary {
+ margin: 0 0 0.75rem;
+ color: var(--bs-secondary-color, #6c757d);
+}
+
+.pat-filemanager-app .filemanager-folderdrop-tree {
+ list-style: none;
+ margin: 0 0 1rem;
+ padding: 0.5rem;
+ max-height: 40vh;
+ overflow: auto;
+ border: 1px solid var(--bs-border-color, var(--filemanager-border));
+ border-radius: var(--bs-border-radius, 0.375rem);
+ background: var(--bs-tertiary-bg, #f8f9fa);
+}
+
+.pat-filemanager-app .filemanager-folderdrop-row {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ padding: 0.15rem 0;
+ padding-inline-start: calc(var(--depth, 0) * 1.25rem);
+}
+
+.pat-filemanager-app .filemanager-folderdrop-name::before {
+ content: "📁 ";
+}
+
+.pat-filemanager-app .filemanager-folderdrop-row.is-root .filemanager-folderdrop-name {
+ font-style: italic;
+ color: var(--bs-secondary-color, #6c757d);
+}
+
+.pat-filemanager-app .filemanager-folderdrop-row.is-root .filemanager-folderdrop-name::before {
+ content: "";
+}
+
+.pat-filemanager-app .filemanager-folderdrop-count {
+ font-size: 0.85em;
+ color: var(--bs-secondary-color, #6c757d);
+}
+
+.pat-filemanager-app .filemanager-folderdrop-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+}
+
+.pat-filemanager-app .filemanager-folderdrop-actions button {
+ display: inline-flex;
+ align-items: center;
+ font: inherit;
+ cursor: pointer;
+ padding: var(--bs-btn-padding-y, 0.375rem) var(--bs-btn-padding-x, 0.75rem);
+ border: 1px solid var(--bs-border-color, var(--filemanager-border));
+ border-radius: var(--bs-btn-border-radius, 0.375rem);
+ background: var(--bs-body-bg, #fff);
+ color: var(--bs-body-color, #212529);
+}
+
+.pat-filemanager-app .filemanager-folderdrop-actions button:hover {
+ background: var(--bs-tertiary-bg, #f8f9fa);
+}
+
+.pat-filemanager-app .filemanager-folderdrop-actions .filemanager-folderdrop-ok {
+ font-weight: 600;
+ border-color: var(--bs-primary, #0d6efd);
+ background: var(--bs-primary, #0d6efd);
+ color: var(--bs-white, #fff);
+}
+
+.pat-filemanager-app .filemanager-folderdrop-actions .filemanager-folderdrop-ok:hover {
+ background: var(--bs-btn-hover-bg, #0b5ed7);
+ border-color: var(--bs-btn-hover-border-color, #0a58ca);
+}
diff --git a/src/pat/filemanager/filemanager.js b/src/pat/filemanager/filemanager.js
new file mode 100644
index 000000000..1ac0b952a
--- /dev/null
+++ b/src/pat/filemanager/filemanager.js
@@ -0,0 +1,78 @@
+import { BasePattern } from "@patternslib/patternslib/src/core/basepattern";
+import Parser from "@patternslib/patternslib/src/core/parser";
+import registry from "@patternslib/patternslib/src/core/registry";
+import utils from "../../core/utils";
+import { mount } from "svelte";
+
+// pat-filemanager — Svelte 5 rewrite of pat-structure folder contents, talking
+// only to plone.restapi. P1: read-only batched listing with server-side sort.
+
+export const parser = new Parser("filemanager");
+
+// No defaults here on purpose: the data attribute carries camelCase keys, and a
+// non-undefined parser default would overwrite the supplied value during the
+// parser's hyphenated->camelCase cleanup. Defaults live in App.svelte props.
+parser.addArgument("context-url");
+parser.addArgument("portal-url");
+parser.addArgument("context-path");
+parser.addArgument("active-columns");
+parser.addArgument("available-columns");
+parser.addArgument("portal-types");
+parser.addArgument("search-index");
+parser.addArgument("default-batch-size");
+parser.addArgument("sort-on");
+parser.addArgument("sort-order");
+parser.addArgument("default-view");
+parser.addArgument("folder-type");
+
+class Pattern extends BasePattern {
+ static name = "filemanager";
+ static trigger = ".pat-filemanager";
+ static parser = parser;
+
+ async init() {
+ import("./filemanager.css");
+
+ // ensure an id on our element
+ let nodeId = this.el.getAttribute("id");
+ if (!nodeId) {
+ nodeId = utils.generateId();
+ this.el.setAttribute("id", nodeId);
+ }
+
+ // default context-url to the current location if not supplied, dropping
+ // a trailing folder_contents view name so restapi calls hit the folder
+ // (the view itself is not a valid @querystring-search traversal target).
+ const contextUrl =
+ this.options.contextUrl ||
+ window.location.href
+ .split("?")[0]
+ .replace(/\/(?:@@)?folder_contents\/?$/, "")
+ .replace(/\/+$/, "");
+
+ // Scope navigation (breadcrumbs / "go up") to the portal root. The
+ // folder_contents view (shared with pat-structure) exposes it as
+ // urlStructure.base — i.e. get_top_site_from_url(), the topmost TTW site.
+ // Honouring it lets the user climb out of a navigation root such as a
+ // plone.app.multilingual language folder (/en, /de, which are
+ // INavigationRoot) back to the portal root, instead of being trapped at
+ // the language root the way a context-only fallback would leave them.
+ const portalUrl =
+ this.options.portalUrl || this.options.urlStructure?.base || "";
+
+ const App = (await import("./src/App.svelte")).default;
+
+ this.component = mount(App, {
+ target: this.el,
+ props: {
+ ...this.options,
+ contextUrl,
+ portalUrl,
+ },
+ });
+ }
+}
+
+registry.register(Pattern);
+export default Pattern;
+export { Pattern };
diff --git a/src/pat/filemanager/filemanager.test.js b/src/pat/filemanager/filemanager.test.js
new file mode 100644
index 000000000..46ca0f807
--- /dev/null
+++ b/src/pat/filemanager/filemanager.test.js
@@ -0,0 +1,41 @@
+import Pattern, { parser } from "./filemanager";
+import registry from "@patternslib/patternslib/src/core/registry";
+import utils from "@patternslib/patternslib/src/core/utils";
+
+describe("pat-filemanager registration", () => {
+ it("registers with the .pat-filemanager trigger", () => {
+ expect(Pattern.name).toBe("filemanager");
+ expect(Pattern.trigger).toBe(".pat-filemanager");
+ expect(registry.patterns.filemanager).toBe(Pattern);
+ });
+
+ it("parses listing options from the data attribute", () => {
+ const el = document.createElement("div");
+ const opts = {
+ contextUrl: "http://nohost/plone/folder",
+ defaultBatchSize: 50,
+ sortOn: "effective",
+ sortOrder: "descending",
+ activeColumns: ["Title", "review_state"],
+ };
+ el.setAttribute("data-pat-filemanager", JSON.stringify(opts));
+
+ const parsed = parser.parse(el, {});
+ expect(parsed.contextUrl).toBe("http://nohost/plone/folder");
+ expect(parsed.defaultBatchSize).toBe(50);
+ expect(parsed.sortOn).toBe("effective");
+ expect(parsed.sortOrder).toBe("descending");
+ expect(parsed.activeColumns).toEqual(["Title", "review_state"]);
+ });
+
+ // Component mount requires Svelte 5 ESM compilation, which this CJS jest
+ // setup does not exercise (see contentbrowser.test.js). Kept skipped.
+ it.skip("mounts the app on scan", async () => {
+ document.body.innerHTML = `
+
`;
+ registry.scan(document.body);
+ await utils.timeout(1);
+ expect(document.querySelectorAll(".pat-filemanager-app").length).toEqual(1);
+ });
+});
diff --git a/src/pat/filemanager/pat-filemanager-spec.md b/src/pat/filemanager/pat-filemanager-spec.md
new file mode 100644
index 000000000..c2fe019a3
--- /dev/null
+++ b/src/pat/filemanager/pat-filemanager-spec.md
@@ -0,0 +1,1202 @@
+# Spec: `pat-filemanager` — Svelte rewrite of pat-structure
+
+A modern, Backbone-free reimplementation of the Plone mockup `pat-structure`
+folder-contents management UI, built on Svelte 5 runes and talking only to
+plone.restapi.
+
+## Decisions (locked)
+
+- **Pattern name:** `pat-filemanager` (trigger `.pat-filemanager`, dir `src/pat/filemanager/`)
+- **State management:** runes in `.svelte.ts` store classes (`$state`/`$derived`), provided to components via `setContext`
+- **Grid / drag & drop:** custom Svelte table + [sortablejs](https://github.com/SortableJS/Sortable) for the drag gesture (the same library pat-contentbrowser uses), wrapped in a small Svelte `use:` action; the decision logic (reorder vs move-into-folder vs move-into-parent) lives in the shared `ListInteractions` controller. sortablejs animates the reorder; external file-drop (upload) stays on native DOM events. See §21. _(Earlier iterations used hand-rolled native HTML5 DnD with `animate:flip`; replaced by sortablejs — §21.)_
+- **API gaps (rename, recursive workflow, bulk PATCH):** pure restapi with client-side loops, **no backend additions**
+- no scss, use pure css or bootstrap, which is present by default in plone
+## 1. Goals & non-goals
+
+- **Goal:** feature parity with `pat-structure` + the new features listed below, on Svelte 5 runes, talking only to plone.restapi.
+- **Drop entirely:** Backbone, Backbone.PageableCollection, underscore, the `window._` / `window.Backbone` globals, DataTables, and all custom Plone JSON views (`/cut`, `/copy`, `/paste`, `/rename`, `/workflow`, `/tags`, `/properties`, `/rearrange`, `/moveitem`, `/setdefaultpage`, `context-info`, and the `vocabularyUrl` GET-with-`query`-param contract).
+- **Loose coupling:** the pattern only needs a context path + portal URL; everything else is discovered through restapi (`@navigation` / `@breadcrumbs`, `@types`, `@querystring`, `@vocabularies`). No dependency on `plone.app.content` browser views.
+- custom listing views: currently we have a table with flexible columns, in the future a grid view for managing images with bigger previews is planned. Also it could be interesting to implement a miller column view like the pat-contentbrowser has and later reuse this in pat-contentbrowser.
+- enable browsing thru folders (including breadcrumbs) in pat-filemanager like pat-structure does.
+
+
+## 2. Directory layout (`src/pat/filemanager/`)
+
+Mirrors the `pat-contentbrowser` split, but state lives in rune classes (`.svelte.ts`):
+
+```
+src/pat/filemanager/
+ filemanager.js # BasePattern wrapper + Parser, mount() Svelte App
+ filemanager.test.js # jest registration + integration tests
+ filemanager.css # pure CSS (no SCSS), flat selectors
+ README.md
+ src/
+ App.svelte # root: reads props -> builds stores, renders layout
+ api/
+ client.ts # thin restapi fetch wrapper (auth header, JSON, errors)
+ contents.ts # @querystring-search, batching, sort
+ operations.ts # copy/move/delete/workflow/patch/ordering/rename
+ upload.ts # @tus-upload (+ plain POST fallback)
+ vocabularies.ts # @vocabularies, @types, @querystring
+ stores/
+ ContentsStore.svelte.ts # results, batch, sort, loading ($state/$derived)
+ SelectionStore.svelte.ts # selected UIDs (page vs all-in-query)
+ ClipboardStore.svelte.ts # cut/copy buffer (client-side clipboard)
+ ColumnsStore.svelte.ts # active/available cols, order, persistence
+ ConfigStore.svelte.ts # immutable config from props
+ components/
+ Toolbar.svelte # batch-action buttons, upload, add menu
+ Breadcrumbs.svelte
+ ContentTable.svelte # table shell, header, select-all, the row s
+ # (checkbox, columns, row menu), DnD + animate:flip
+ ColumnCell.svelte # renders a value by column type (date/state/tags/image)
+ RowActionMenu.svelte # open/edit/cut/copy/paste/move-top/bottom/default-page
+ Pagination.svelte # batch size + page nav
+ ColumnsConfig.svelte # toggle + drag-reorder columns popover
+ FilterBar.svelte # text search + querystring criteria
+ UploadZone.svelte # multi-upload button + drag/drop to listing
+ BatchActionModal.svelte # native host for workflow/tags/properties/rename
+ modals/
+ WorkflowForm.svelte
+ PropertiesForm.svelte
+ TagsForm.svelte
+ RenameForm.svelte
+ StatusMessages.svelte
+ utils/
+ format.ts # date formatting (real Date), size, i18n bridge
+ dnd.ts # native HTML5 drag/drop svelte actions
+```
+
+State management detail: each store is a class exposing `$state` fields and
+`$derived` getters. `App.svelte` instantiates them and passes them via
+`setContext` so deep components stay decoupled, while the state itself is
+rune-based (no `svelte/store` writables).
+
+## 3. restapi endpoint mapping (pure restapi, client-side loops)
+
+| Legacy custom endpoint | New plone.restapi call |
+|---|---|
+| `vocabularyUrl` (GET `?query=&attributes=&batch=`) | `POST {context}/@querystring-search` with `{query, metadata_fields, b_start, b_size, sort_on, sort_order}` |
+| filter widget config (`indexOptionsUrl`) | `GET @querystring` |
+| `contextInfoUrl` (breadcrumbs) | `GET @breadcrumbs` (NOTE: no add-new menu — adding content is out of scope) |
+| `/cut`, `/copy` | **client-side** clipboard (`ClipboardStore`): record `{op, sources:[path]}` |
+| `/paste` | `POST {targetFolder}/@move` (cut) or `POST {targetFolder}/@copy` (copy), body `{source:[...]}` |
+| `/delete` | `DELETE {item}` looped over selection |
+| `/rename` | `POST {parent}/@move` with new `id` (rename = move-in-place); looped |
+| `/workflow` (+recursive) | `POST {item}/@workflow/{transition}`; recursive = walk descendants via `@search` then loop |
+| `/tags` | `PATCH {item}` `{Subject:[...]}`, looped |
+| `/properties` (+recursive) | `PATCH {item}` `{effective, expires, rights, creators, contributors, exclude_from_nav, language}`, looped (recursive walks descendants) |
+| `/rearrange` (sort folder) | `PATCH {folder}` `{"sort":{"on":"","order":"ascending"|"descending"}}` — full resort in one call; per-item: `{ordering:{obj_id, delta}}` |
+| `moveUrl` move top/bottom | `PATCH {folder}` `{ordering:{obj_id:, delta:"top"|"bottom"}}` |
+| `setDefaultPageUrl` | `PATCH {folder}` `{default_page:}` |
+| workflow states / languages vocab | `GET @vocabularies/{name}` |
+| multi-upload | `@tus-upload` (resumable) with plain `POST {folder}` File/Image fallback |
+| image preview | request `image_scales` in `metadata_fields`; render `{base}/@@images/{field}/{scale}` |
+
+> **Verify in Phase 0** (not yet deep-checked against source): exact payload for
+> `ordering` / `default_page` on content `PATCH`, and whether `@move` accepts a
+> renaming `id`. If `@move`+id doesn't rename, rename falls back to looping but
+> stays pure-restapi. No backend changes planned.
+
+Confirmed locally available restapi services:
+`querystringsearch`, `querystring`, `copymove`, `workflow`, `content`
+(add/update/delete/tus), `vocabularies`, `types`, `breadcrumbs`, `navigation`,
+`locking`.
+
+## 4. Solving the stated pain points & new features
+
+- **Sort across whole result, not current batch:** `@querystring-search` sorts in the catalog via `sort_on`/`sort_order` *before* batching with `b_start`/`b_size`, so a column-sort re-queries the server and orders the entire result set. This is the core fix vs DataTables-sorts-current-page.
+- **Real date sort:** sort on the catalog date index (`effective`, `created`, `modified`, `expires`) — the catalog sorts dates as dates, not text.
+- **Customizable batch size:** `b_size` bound to `ContentsStore`, persisted in a cookie (`utils/storage.ts` `cookieStorage`), the same way legacy pat-structure stored it.
+- **Image column / preview:** new `image` column type rendering a thumbnail scale, driven by `image_scales` metadata.
+- **Drag-into-folder:** native DnD action; a folder row/card splits into drop **zones** — dropping on its central band → `@move` selected sources into that folder; dropping on its edge bands reorders relative to it (see §17). The grid also renders an **"up to parent"** placeholder that accepts a drop to move the sources into the parent container (or upload files there).
+- **Multi-upload via drag/drop onto listing:** `UploadZone` overlay on `ContentTable`; drop files → `@tus-upload` to the current folder. Dropping files **directly onto a subfolder** row/card uploads into that folder instead (the row claims the drop and the zone skips it).
+- **Select "current batch" vs "all in query":** `SelectionStore` tracks a mode; "all" runs a UID-only `@querystring-search` sweep (paged loop, like the legacy `selectAll`).
+- **Persistent reorder:** ordering PATCH against the catalog `getObjPositionInParent`. The rows reorder optimistically and animate into place with `animate:flip` (see §19).
+
+## 5. Feature parity checklist (from pat-structure)
+
+- [x] Batched listing on content
+- [x] Customizable batch size
+- [x] Drag / drop sorting (persistent, catalog-index based; flip-animated — §19)
+- [x] Rearrange folder (full-sort by criterion via `OrderingMixin sort` PATCH — §24)
+- [x] Visible columns configuration (toggle + reorder + persist)
+- [x] Select items: current batch or all-in-query
+- [x] Multi-upload button (P5 — `@tus-upload` + plain-POST fallback)
+- [x] Batch actions: cut, copy, paste, delete (P3); rename, tagging (P4)
+- [x] Workflow status (with optional recursive application) (P4)
+- [x] Edit properties (with optional recursive): publication date, expiration date, copyright/rights, creators, contributors, exclude_from_nav, language (P4)
+- [x] Search + querystring filter (free-text + portal_type + advanced query builder: arbitrary `plone.app.querystring` criteria via `QueryBuilder.svelte`, like pat-structure)
+- [x] Image preview in item listing (P1 — `image` column type, `thumbnailUrl`)
+- [x] Per-item actions: move to top, move to bottom, cut, copy, set as default page
+- [x] Breadcrumbs (with in-app folder browsing; syncs the Plone toolbar — §15; no add-new menu — adding content is out of scope)
+- [x] Status messages (P4)
+- [x] use a cookie to store batch size and visible columns (§19)
+
+New features:
+
+- [x] Drag into folder (single or multiple selected items) (P5)
+- [x] Multi-upload directly to listing via drag/drop (P5)
+- [x] Drop a folder to recreate it + upload its contents, with preview/approval (§26)
+- [x] Column visual (non-persistent) sort applied over whole result set (P1)
+- [x] Date columns sort by real date (P1 — catalog date index)
+- [x] New `image` column (P1)
+- [x] Allow switching views for the listing (table, grid for organizing photos, later maybe pat-contentbrowser style) (P7 — see §20)
+
+## 6. Pattern registration & build (no build-system changes needed)
+
+- `filemanager.js`: `class extends BasePattern`, `static trigger = ".pat-filemanager"`, a `Parser("filemanager")` declaring args (`context-url`, `portal-url`, `active-columns`, `available-columns`, `default-batch-size`, `sort-on`, `sort-order`, `upload`, etc.), then `mount(App, {target, props:{...this.options}})` — exactly the contentbrowser shape (`src/pat/contentbrowser/contentbrowser.js:54-91`).
+- Register the import in `src/patterns.js` (next to the contentbrowser line at `:23`).
+- webpack already compiles `.svelte` (svelte-loader, runes on via `svelte.config.js`); the pattern's pure-CSS file is imported from the wrapper's `init()` (the base config rule `/\.(?:sass|scss|css)$/` handles it). Nothing to add to webpack / jest config.
+
+## 7. Testing
+
+- jest + the existing `src/setup-tests.js` for pattern registration (mirror `src/pat/contentbrowser/contentbrowser.test.js`): scan DOM, assert mount.
+- Unit-test the rune store classes (`.svelte.ts`) and `api/*` modules with mocked `fetch` (jsdom).
+- Real tests only — no ad-hoc verification scripts.
+- Manual UI verification in the running dev server (do not auto-start it; request a start when needed).
+
+## 8. Phasing (incremental, each phase shippable)
+
+1. **P0 – API spike:** `api/client` + `contents`; confirm `@querystring-search` batching/sort and the ordering / default_page / move-rename payloads. **DONE — see section 9.**
+2. **P1 – Read-only listing:** ConfigStore, ContentsStore, ContentTable/Row, Breadcrumbs, Pagination, server-side column sort, image column. Feature-flagged alongside pat-structure. **DONE — see section 11.**
+3. **P2 – Columns + filter:** ColumnsConfig (toggle/reorder/persist), FilterBar (`@querystring`). **DONE — see section 12.**
+4. **P3 – Selection + clipboard:** SelectionStore (page/all), cut/copy/paste, delete, move top/bottom, set-default-page, ordering DnD. **DONE — see section 13.**
+5. **P4 – Batch modals:** workflow (+recursive), tags, properties (+recursive), rename. **DONE — see section 14.**
+6. **P5 – Upload:** multi-upload button + drag/drop-to-listing via `@tus-upload`; drag-into-folder. **DONE — see section 17.**
+7. **P6 – Polish:** i18n, a11y, docs (styling stays pure CSS — no scss port), parity audit against section 5. **DONE — see section 18.**
+8. **P7 – Switchable views:** ViewStore + view switcher, extract shared selection/drag logic, add a grid view for organizing photos (table ⇄ grid; miller later). **DONE — see section 20.**
+
+## 9. P0 spike results (verified against local plone.restapi source)
+
+Source: `src/plone.restapi/src/plone/restapi/...`
+
+### @querystring-search (`services/querystringsearch/get.py`)
+- **Use POST**, body `{query:[criteria], sort_on, sort_order, b_start, b_size, limit, fullobjects, metadata_fields}`.
+- `query` IS the plone.app.querystring criteria list (`[{i,o,v}]`), not wrapped in `{criteria}`.
+- Defaults: `b_size=25`, `limit=1000`. Sorting runs in the querybuilder **before** batching → whole-result sort (fixes the legacy current-batch-only sort).
+- `metadata_fields`: the summary serializer (`serializer/summary.py:106-110`) reads it from `request.form`, and **falls back to the JSON body on POST**. `"_all"` expands to all catalog columns (`catalog.schema()`), incl. `image_scales` when present. So POST delivers our columns.
+- **GET wipes `request.form`** (`get.py:104`) → metadata_fields lost. Therefore always POST.
+- The service auto-excludes the **context's own UID** from results → call it on the folder being listed.
+
+### Ordering / rearrange (`deserializer/mixins.py` `OrderingMixin`)
+- PATCH the **container**:
+ - Move one item: `{"ordering":{"obj_id":"", "delta":"top"|"bottom"|, "subset_ids":[...]}}` — covers move-top, move-bottom, and relative DnD.
+ - Full resort: `{"sort":{"on":"", "order":"ascending"|"descending"}}` — covers the legacy `/rearrange` in one call.
+- `subset_ids` (if passed) must match current server order or it raises `400 Client/server ordering mismatch`. For DnD in a filtered/batched view, send the visible ids in their current order.
+
+### @copy / @move (`services/copymove/copymove.py`)
+- POST to the **target container**: `{"source":[path|url|uid, ...]}`. Returns `[{source, target}]` with server-assigned `new_id`.
+- `@move` does **not** accept a rename id → **rename is NOT covered by @move**. Rename strategy: verify a live instance for any `@rename`-style support; otherwise rename needs a small fallback (the one true gap besides default_page). Both stay within "no pat-structure-custom endpoints".
+
+### default_page (gap)
+- No dedicated deserializer; only settable if the container schema exposes a writable `default_page` field. **Verify on a live instance in P1**; set-default-page may need a fallback.
+
+## 10. Toolchain note (blocks the runes-based stores in P1)
+
+The repo has **no TypeScript toolchain**: no tsconfig, no ts-loader/ts-jest/@babel/preset-typescript, and webpack `resolve.extensions` is `.js/.json/.wasm/.svelte` only. Also, runes-in-module files (`.svelte.js` / `.svelte.ts`) are **not** matched by the current webpack svelte rule (`test:/\.svelte$/`) nor by jest (`svelte-jester` only matches `.svelte`). `pat-contentbrowser` avoids this by using `svelte/store` writables, not runes-in-modules.
+
+**Consequence:** the chosen "runes in `.svelte.ts` store classes" requires, before P1 stores land:
+1. A webpack rule running `svelte-loader` over `\.svelte\.(js|ts)$` (and adding those to `resolve.extensions`).
+2. A matching jest transform for `\.svelte\.(js|ts)$`.
+3. If full TS is wanted: add `typescript` + `svelte-preprocess` + `tsconfig.json` + jest TS transform. Otherwise use `.svelte.js` (runes, JSDoc types) and skip the TS deps.
+
+P0 api modules need no runes, so they ship as plain `.js` (matching contentbrowser) and are unaffected.
+
+### Toolchain — DONE (unblocks P1)
+
+The runes/TS toolchain is now wired up. Key gotcha discovered: svelte-loader and
+svelte-jester compile `.svelte.ts` module files with `svelte.compileModule`, which
+does **not** strip TypeScript and does **not** run `preprocess` (preprocess only
+touches `
+
+
+
+
+
+
+
+
+
+
+ {#if view.mode === "grid"}
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/src/pat/filemanager/src/api/breadcrumbs.js b/src/pat/filemanager/src/api/breadcrumbs.js
new file mode 100644
index 000000000..c7deba5b2
--- /dev/null
+++ b/src/pat/filemanager/src/api/breadcrumbs.js
@@ -0,0 +1,60 @@
+import { request } from "./client.js";
+
+/**
+ * Fetch the breadcrumb trail for a context via plone.restapi @breadcrumbs.
+ *
+ * @param {string} contextUrl - absolute url of the folder
+ * @returns {Promise<{items: Array<{"@id": string, title: string}>, root: string|null}>}
+ */
+export async function fetchBreadcrumbs(contextUrl) {
+ const data = await request(`${contextUrl}/@breadcrumbs`);
+ return {
+ items: data?.items || [],
+ root: data?.root || null,
+ };
+}
+
+/** Strip a trailing slash (so url joins/comparisons are stable). */
+function stripTrailingSlash(url) {
+ return (url || "").replace(/\/+$/, "");
+}
+
+/**
+ * Build the breadcrumb trail the filemanager actually renders, spanning the
+ * whole portal rather than stopping at the navigation root.
+ *
+ * plone.restapi's @breadcrumbs stops at the navigation root: its `items` never
+ * include the navigation root or anything above it, and `root` points at the
+ * navigation root. In plone.app.multilingual the language folders (/en, /de)
+ * are INavigationRoot, so browsing /en/foo returns root=/en and items=[foo] —
+ * /en itself is never listed and "Home" would jump to /en, trapping the user
+ * inside one language with no way to climb to the portal root and switch.
+ *
+ * Up-navigation (canGoUp/parentUrl) is already scoped to config.portalUrl, so
+ * the trail should match: we rebuild the crumbs the endpoint omits — every
+ * path segment from the portal root down to and including the navigation root
+ * — from the url, and rebase "Home" on the portal root. The segment id (the
+ * language code for a language root folder) is used as the crumb title.
+ *
+ * For ordinary sites the navigation root equals the portal root, so no crumbs
+ * are added and the trail is unchanged.
+ *
+ * @param {{items: Array, root: string|null, portalUrl: string}} args
+ * @returns {{items: Array<{"@id": string, title: string}>, home: string}}
+ */
+export function buildBreadcrumbTrail({ items = [], root = null, portalUrl }) {
+ const portal = stripTrailingSlash(portalUrl);
+ const navRoot = stripTrailingSlash(root);
+ const ancestors = [];
+ // Only fill the gap when the navigation root sits strictly below the portal
+ // root (the multilingual / subsite case); guard with the "/" boundary so a
+ // shared prefix like /plone vs /plone-two can't be mistaken for an ancestor.
+ if (navRoot && navRoot !== portal && navRoot.startsWith(`${portal}/`)) {
+ let url = portal;
+ for (const seg of navRoot.slice(portal.length + 1).split("/")) {
+ url = `${url}/${seg}`;
+ ancestors.push({ "@id": url, title: seg });
+ }
+ }
+ return { items: [...ancestors, ...items], home: portal };
+}
diff --git a/src/pat/filemanager/src/api/breadcrumbs.test.js b/src/pat/filemanager/src/api/breadcrumbs.test.js
new file mode 100644
index 000000000..7bc3ca1d8
--- /dev/null
+++ b/src/pat/filemanager/src/api/breadcrumbs.test.js
@@ -0,0 +1,127 @@
+import { fetchBreadcrumbs, buildBreadcrumbTrail } from "./breadcrumbs.js";
+import { request } from "./client.js";
+
+jest.mock("./client.js", () => ({ request: jest.fn() }));
+
+const mockedRequest = request;
+
+beforeEach(() => {
+ mockedRequest.mockReset();
+});
+
+describe("fetchBreadcrumbs", () => {
+ it("GETs the @breadcrumbs endpoint and returns items + root", async () => {
+ mockedRequest.mockResolvedValue({
+ "@id": "http://nohost/plone/folder/@breadcrumbs",
+ root: "http://nohost/plone",
+ items: [{ "@id": "http://nohost/plone/folder", title: "Folder" }],
+ });
+ const data = await fetchBreadcrumbs("http://nohost/plone/folder");
+ expect(mockedRequest).toHaveBeenCalledWith(
+ "http://nohost/plone/folder/@breadcrumbs"
+ );
+ expect(data).toEqual({
+ items: [{ "@id": "http://nohost/plone/folder", title: "Folder" }],
+ root: "http://nohost/plone",
+ });
+ });
+
+ it("defaults items to [] and root to null when missing", async () => {
+ mockedRequest.mockResolvedValue({});
+ const data = await fetchBreadcrumbs("http://nohost/plone");
+ expect(data).toEqual({ items: [], root: null });
+ });
+});
+
+describe("buildBreadcrumbTrail", () => {
+ it("leaves the trail unchanged when the navigation root is the portal root", () => {
+ const trail = buildBreadcrumbTrail({
+ items: [{ "@id": "http://nohost/plone/folder", title: "Folder" }],
+ root: "http://nohost/plone",
+ portalUrl: "http://nohost/plone",
+ });
+ expect(trail).toEqual({
+ items: [{ "@id": "http://nohost/plone/folder", title: "Folder" }],
+ home: "http://nohost/plone",
+ });
+ });
+
+ it("prepends the language root folder and rebases Home on the portal root (multilingual)", () => {
+ // Browsing /en/foo in a PAM site: @breadcrumbs stops at /en (the nav
+ // root) and lists only [foo]; /en itself is omitted.
+ const trail = buildBreadcrumbTrail({
+ items: [{ "@id": "http://nohost/plone/en/foo", title: "Foo" }],
+ root: "http://nohost/plone/en",
+ portalUrl: "http://nohost/plone",
+ });
+ expect(trail).toEqual({
+ items: [
+ { "@id": "http://nohost/plone/en", title: "en" },
+ { "@id": "http://nohost/plone/en/foo", title: "Foo" },
+ ],
+ home: "http://nohost/plone",
+ });
+ });
+
+ it("shows the language root itself as the active (last) crumb when browsing it", () => {
+ // Browsing /en directly: @breadcrumbs returns no items.
+ const trail = buildBreadcrumbTrail({
+ items: [],
+ root: "http://nohost/plone/de",
+ portalUrl: "http://nohost/plone",
+ });
+ expect(trail).toEqual({
+ items: [{ "@id": "http://nohost/plone/de", title: "de" }],
+ home: "http://nohost/plone",
+ });
+ });
+
+ it("fills in every segment for a navigation root nested several levels deep", () => {
+ const trail = buildBreadcrumbTrail({
+ items: [{ "@id": "http://nohost/plone/a/b/en/foo", title: "Foo" }],
+ root: "http://nohost/plone/a/b/en",
+ portalUrl: "http://nohost/plone",
+ });
+ expect(trail.items).toEqual([
+ { "@id": "http://nohost/plone/a", title: "a" },
+ { "@id": "http://nohost/plone/a/b", title: "b" },
+ { "@id": "http://nohost/plone/a/b/en", title: "en" },
+ { "@id": "http://nohost/plone/a/b/en/foo", title: "Foo" },
+ ]);
+ expect(trail.home).toBe("http://nohost/plone");
+ });
+
+ it("tolerates trailing slashes on portalUrl and root", () => {
+ const trail = buildBreadcrumbTrail({
+ items: [],
+ root: "http://nohost/plone/en/",
+ portalUrl: "http://nohost/plone/",
+ });
+ expect(trail).toEqual({
+ items: [{ "@id": "http://nohost/plone/en", title: "en" }],
+ home: "http://nohost/plone",
+ });
+ });
+
+ it("does not treat a shared name prefix as an ancestor", () => {
+ // /plone-two is not below /plone even though the strings share a prefix.
+ const trail = buildBreadcrumbTrail({
+ items: [{ "@id": "http://nohost/plone-two/x", title: "X" }],
+ root: "http://nohost/plone-two",
+ portalUrl: "http://nohost/plone",
+ });
+ expect(trail.items).toEqual([
+ { "@id": "http://nohost/plone-two/x", title: "X" },
+ ]);
+ expect(trail.home).toBe("http://nohost/plone");
+ });
+
+ it("falls back to the portal root for Home when root is null", () => {
+ const trail = buildBreadcrumbTrail({
+ items: [],
+ root: null,
+ portalUrl: "http://nohost/plone",
+ });
+ expect(trail).toEqual({ items: [], home: "http://nohost/plone" });
+ });
+});
diff --git a/src/pat/filemanager/src/api/client.js b/src/pat/filemanager/src/api/client.js
new file mode 100644
index 000000000..7fb857ff4
--- /dev/null
+++ b/src/pat/filemanager/src/api/client.js
@@ -0,0 +1,81 @@
+import logger from "@patternslib/patternslib/src/core/logging";
+
+const log = logger.getLogger("pat-filemanager");
+
+export class RestapiError extends Error {
+ constructor(message, { status, body } = {}) {
+ super(message);
+ this.name = "RestapiError";
+ this.status = status;
+ this.body = body;
+ }
+}
+
+/**
+ * Thin wrapper around fetch for plone.restapi calls.
+ *
+ * Always sends/accepts application/json and includes same-origin credentials
+ * (the logged-in Plone session cookie). plone.restapi exempts its own services
+ * from plone.protect CSRF, so no _authenticator is needed.
+ *
+ * @param {string} url - absolute or root-relative endpoint url
+ * @param {object} [opts]
+ * @param {string} [opts.method="GET"]
+ * @param {object} [opts.body] - serialized to JSON for write verbs
+ * @param {object} [opts.params] - appended as query string
+ * @param {object} [opts.headers]
+ * @returns {Promise} parsed JSON, or null for 204 No Content
+ */
+export async function request(url, { method = "GET", body, params, headers } = {}) {
+ let finalUrl = url;
+ if (params) {
+ const usp = new URLSearchParams();
+ for (const [key, value] of Object.entries(params)) {
+ if (value === undefined || value === null) continue;
+ if (Array.isArray(value)) {
+ for (const v of value) usp.append(key, v);
+ } else {
+ usp.append(key, value);
+ }
+ }
+ const qs = usp.toString();
+ if (qs) finalUrl += (finalUrl.includes("?") ? "&" : "?") + qs;
+ }
+
+ const requestHeaders = new Headers(headers);
+ requestHeaders.set("Accept", "application/json");
+
+ const init = { method, headers: requestHeaders, credentials: "same-origin" };
+ if (body !== undefined) {
+ requestHeaders.set("Content-Type", "application/json");
+ init.body = JSON.stringify(body);
+ }
+
+ log.debug(`${method} ${finalUrl}`, body);
+
+ const response = await fetch(finalUrl, init);
+
+ if (response.status === 204) {
+ return null;
+ }
+
+ let payload = null;
+ const text = await response.text();
+ if (text) {
+ try {
+ payload = JSON.parse(text);
+ } catch {
+ payload = text;
+ }
+ }
+
+ if (!response.ok) {
+ const message =
+ (payload && payload.error && payload.error.message) ||
+ (payload && payload.message) ||
+ `Request failed with status ${response.status}`;
+ throw new RestapiError(message, { status: response.status, body: payload });
+ }
+
+ return payload;
+}
diff --git a/src/pat/filemanager/src/api/contents.js b/src/pat/filemanager/src/api/contents.js
new file mode 100644
index 000000000..776de25dc
--- /dev/null
+++ b/src/pat/filemanager/src/api/contents.js
@@ -0,0 +1,109 @@
+import { request } from "./client.js";
+
+const PATH_OP = "plone.app.querystring.operation.string.path";
+const TYPE_OP = "plone.app.querystring.operation.selection.any";
+const TEXT_OP = "plone.app.querystring.operation.string.contains";
+
+/**
+ * Build the plone.app.querystring criteria list for a folder listing.
+ *
+ * @param {object} args
+ * @param {string} args.path - physical path of the folder to list
+ * @param {number} [args.depth=1] - path depth (1 = direct children)
+ * @param {string[]} [args.portalTypes] - restrict to these types
+ * @param {string} [args.searchableText] - free-text filter
+ * @param {string} [args.searchIndex="SearchableText"]
+ * @param {Array} [args.extraCriteria] - additional raw criteria from the filter widget
+ * @returns {Array} criteria
+ */
+export function buildCriteria({
+ path,
+ depth = 1,
+ portalTypes = [],
+ searchableText = "",
+ searchIndex = "SearchableText",
+ extraCriteria = [],
+} = {}) {
+ const criteria = [
+ { i: "path", o: PATH_OP, v: `${path}::${depth}` },
+ ];
+ if (portalTypes.length) {
+ criteria.push({ i: "portal_type", o: TYPE_OP, v: portalTypes });
+ }
+ if (searchableText) {
+ criteria.push({ i: searchIndex, o: TEXT_OP, v: searchableText });
+ }
+ return [...criteria, ...extraCriteria];
+}
+
+/**
+ * Criteria matching a whole subtree (the item itself and all descendants).
+ *
+ * Omitting the `::depth` suffix lets the catalog path index match every object
+ * beneath `path`. @querystring-search additionally excludes the context's own
+ * UID, so calling it on the item's url yields just the descendants — which is
+ * exactly what the recursive properties walk needs.
+ *
+ * @param {string} path - physical path of the subtree root
+ * @returns {Array} criteria
+ */
+export function buildSubtreeCriteria(path) {
+ return [{ i: "path", o: PATH_OP, v: path }];
+}
+
+/**
+ * List folder contents via plone.restapi @querystring-search.
+ *
+ * Uses POST so that metadata_fields is read from the JSON body (GET resets
+ * request.form) and so long UID lists don't overflow the URL. Sorting is done
+ * by the catalog over the whole result set before batching, which is the core
+ * fix vs. the legacy DataTables (current-batch-only) sorting.
+ *
+ * NOTE: @querystring-search excludes the context's own UID from results, so
+ * call it on the folder being listed.
+ *
+ * @param {object} args
+ * @param {string} args.contextUrl - absolute url of the folder (endpoint base)
+ * @param {Array} args.criteria - querystring criteria (see buildCriteria)
+ * @param {string} [args.sortOn] - catalog index to sort on (dates sort as dates)
+ * @param {string} [args.sortOrder] - "ascending" | "descending"
+ * @param {number} [args.bStart=0] - batch start offset
+ * @param {number} [args.bSize=25] - batch size
+ * @param {number} [args.limit=1000] - hard result cap
+ * @param {string[]} [args.metadataFields=["_all"]] - catalog columns to return
+ * @param {boolean} [args.fullobjects=false]
+ * @returns {Promise<{items: Array, total: number, batching: object|null}>}
+ */
+export async function searchContents({
+ contextUrl,
+ criteria,
+ sortOn,
+ sortOrder,
+ bStart = 0,
+ bSize = 25,
+ limit = 1000,
+ metadataFields = ["_all"],
+ fullobjects = false,
+}) {
+ const body = {
+ query: criteria,
+ b_start: bStart,
+ b_size: bSize,
+ limit,
+ metadata_fields: metadataFields,
+ fullobjects,
+ };
+ if (sortOn) body.sort_on = sortOn;
+ if (sortOrder) body.sort_order = sortOrder;
+
+ const data = await request(`${contextUrl}/@querystring-search`, {
+ method: "POST",
+ body,
+ });
+
+ return {
+ items: data?.items || [],
+ total: data?.items_total ?? 0,
+ batching: data?.batching || null,
+ };
+}
diff --git a/src/pat/filemanager/src/api/contents.test.js b/src/pat/filemanager/src/api/contents.test.js
new file mode 100644
index 000000000..adf0003de
--- /dev/null
+++ b/src/pat/filemanager/src/api/contents.test.js
@@ -0,0 +1,137 @@
+import { buildCriteria, searchContents } from "./contents";
+import { request } from "./client";
+
+function mockFetch({ status = 200, json = {}, text } = {}) {
+ const body = text !== undefined ? text : JSON.stringify(json);
+ global.fetch = jest.fn().mockResolvedValue({
+ ok: status >= 200 && status < 300,
+ status,
+ text: () => Promise.resolve(body),
+ });
+}
+
+afterEach(() => {
+ jest.restoreAllMocks();
+ delete global.fetch;
+});
+
+describe("buildCriteria", () => {
+ it("scopes to direct children by default", () => {
+ const criteria = buildCriteria({ path: "/plone/folder" });
+ expect(criteria).toEqual([
+ {
+ i: "path",
+ o: "plone.app.querystring.operation.string.path",
+ v: "/plone/folder::1",
+ },
+ ]);
+ });
+
+ it("adds portal_type and SearchableText criteria", () => {
+ const criteria = buildCriteria({
+ path: "/plone/folder",
+ portalTypes: ["Document", "Folder"],
+ searchableText: "hello",
+ });
+ expect(criteria).toContainEqual({
+ i: "portal_type",
+ o: "plone.app.querystring.operation.selection.any",
+ v: ["Document", "Folder"],
+ });
+ expect(criteria).toContainEqual({
+ i: "SearchableText",
+ o: "plone.app.querystring.operation.string.contains",
+ v: "hello",
+ });
+ });
+
+ it("appends extra criteria from the filter widget", () => {
+ const extra = { i: "review_state", o: "x", v: "published" };
+ const criteria = buildCriteria({ path: "/p", extraCriteria: [extra] });
+ expect(criteria[criteria.length - 1]).toBe(extra);
+ });
+});
+
+describe("searchContents", () => {
+ it("POSTs query, sort, batch and metadata_fields in the JSON body", async () => {
+ mockFetch({
+ json: {
+ items: [{ UID: "a" }, { UID: "b" }],
+ items_total: 42,
+ batching: { next: "..." },
+ },
+ });
+
+ const result = await searchContents({
+ contextUrl: "http://nohost/plone/folder",
+ criteria: buildCriteria({ path: "/plone/folder" }),
+ sortOn: "effective",
+ sortOrder: "descending",
+ bStart: 25,
+ bSize: 25,
+ metadataFields: ["EffectiveDate", "image_scales"],
+ });
+
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ const [url, init] = global.fetch.mock.calls[0];
+ expect(url).toBe("http://nohost/plone/folder/@querystring-search");
+ expect(init.method).toBe("POST");
+ const sentBody = JSON.parse(init.body);
+ expect(sentBody.sort_on).toBe("effective");
+ expect(sentBody.sort_order).toBe("descending");
+ expect(sentBody.b_start).toBe(25);
+ expect(sentBody.b_size).toBe(25);
+ expect(sentBody.metadata_fields).toEqual(["EffectiveDate", "image_scales"]);
+ expect(sentBody.query[0].i).toBe("path");
+
+ expect(result.items).toHaveLength(2);
+ expect(result.total).toBe(42);
+ expect(result.batching).toEqual({ next: "..." });
+ });
+
+ it("omits sort params when not provided", async () => {
+ mockFetch({ json: { items: [], items_total: 0 } });
+ await searchContents({
+ contextUrl: "http://nohost/plone",
+ criteria: [],
+ });
+ const sentBody = JSON.parse(global.fetch.mock.calls[0][1].body);
+ expect("sort_on" in sentBody).toBe(false);
+ expect("sort_order" in sentBody).toBe(false);
+ });
+});
+
+describe("client.request", () => {
+ it("returns null for 204 No Content", async () => {
+ mockFetch({ status: 204, text: "" });
+ const result = await request("http://nohost/plone/item", {
+ method: "DELETE",
+ });
+ expect(result).toBeNull();
+ });
+
+ it("throws RestapiError with status and parsed message on failure", async () => {
+ mockFetch({
+ status: 400,
+ json: { error: { type: "BadRequest", message: "Invalid query." } },
+ });
+ await expect(
+ request("http://nohost/plone/@querystring-search", { method: "POST", body: {} })
+ ).rejects.toMatchObject({
+ name: "RestapiError",
+ status: 400,
+ message: "Invalid query.",
+ });
+ });
+
+ it("serializes body as JSON and appends array params", async () => {
+ mockFetch({ json: {} });
+ await request("http://nohost/plone/@search", {
+ params: { metadata_fields: ["a", "b"], sort_on: "modified" },
+ });
+ const url = global.fetch.mock.calls[0][0];
+ expect(url).toContain("metadata_fields=a");
+ expect(url).toContain("metadata_fields=b");
+ expect(url).toContain("sort_on=modified");
+ });
+});
diff --git a/src/pat/filemanager/src/api/operations.js b/src/pat/filemanager/src/api/operations.js
new file mode 100644
index 000000000..42472ee0f
--- /dev/null
+++ b/src/pat/filemanager/src/api/operations.js
@@ -0,0 +1,130 @@
+import { request } from "./client.js";
+
+// Write operations against plone.restapi. All endpoints are stock restapi
+// services (copymove, content delete, ordering/default_page deserializers) —
+// no pat-structure custom JSON views. See spec §3 / §9 for the mapping.
+
+/** Last path segment of a content url/path (the object id within its parent). */
+export function objId(urlOrPath) {
+ return String(urlOrPath || "")
+ .split(/[?#]/)[0]
+ .replace(/\/+$/, "")
+ .split("/")
+ .pop() || "";
+}
+
+/**
+ * Cut/copy paste into a target container via @move (cut) or @copy (copy).
+ *
+ * @param {object} args
+ * @param {string} args.targetUrl - container the items are pasted into
+ * @param {string[]} args.sources - source urls/paths/uids to move or copy
+ * @param {"cut"|"copy"} args.op
+ * @returns {Promise>}
+ */
+export function pasteItems({ targetUrl, sources, op }) {
+ const endpoint = op === "cut" ? "@move" : "@copy";
+ return request(`${targetUrl}/${endpoint}`, {
+ method: "POST",
+ body: { source: sources },
+ });
+}
+
+/** DELETE a single content item by its url. */
+export function deleteItem(itemUrl) {
+ return request(itemUrl, { method: "DELETE" });
+}
+
+/**
+ * Delete several items, sequentially (one DELETE each — restapi has no bulk
+ * delete). Resolves once all are gone. `onStep(done, total)` (optional) is
+ * called after each delete so callers can show progress.
+ */
+export async function deleteItems(itemUrls, onStep) {
+ let done = 0;
+ for (const url of itemUrls) {
+ await deleteItem(url);
+ onStep?.(++done, itemUrls.length);
+ }
+}
+
+/**
+ * Check link integrity for a set of items (by UID) before deletion.
+ * Returns an array of items; each entry has a `breaches` array listing the
+ * sources that reference it — only items with breaches need to be surfaced.
+ *
+ * @param {string} contextUrl - base URL for the /@linkintegrity endpoint
+ * @param {string[]} uids
+ * @returns {Promise>}
+ */
+export function checkLinkIntegrity(contextUrl, uids) {
+ if (uids.length === 0) return Promise.resolve([]);
+ const params = new URLSearchParams();
+ for (const uid of uids) params.append("uids", uid);
+ return request(`${contextUrl}/@linkintegrity?${params}`);
+}
+
+/**
+ * Reorder one item within its container via the OrderingMixin deserializer.
+ *
+ * @param {object} args
+ * @param {string} args.containerUrl
+ * @param {string} args.id - the object id to move
+ * @param {"top"|"bottom"|number} args.delta - absolute position or relative shift
+ * @param {string[]} [args.subsetIds] - current server order of the visible subset
+ * (required for relative moves in a filtered/batched view; must match the
+ * server order or restapi answers 400)
+ */
+export function moveItem({ containerUrl, id, delta, subsetIds }) {
+ const ordering = { obj_id: id, delta };
+ if (subsetIds) ordering.subset_ids = subsetIds;
+ return request(containerUrl, { method: "PATCH", body: { ordering } });
+}
+
+/** Set the container's default page to one of its children (by id). */
+export function setDefaultPage({ containerUrl, id }) {
+ return request(containerUrl, { method: "PATCH", body: { default_page: id } });
+}
+
+/**
+ * PATCH one content item with a partial body.
+ *
+ * Used by the batch modals (tags, properties, rename). Stock content update —
+ * the deserializer only writes schema fields / ordering / layout it recognises,
+ * ignoring the rest. Rename mirrors Volto: send `{id, title}` (id-honouring
+ * depends on backend support; see spec §9).
+ */
+export function patchItem(itemUrl, data) {
+ return request(itemUrl, { method: "PATCH", body: data });
+}
+
+/**
+ * PATCH the same body into several items sequentially (no bulk PATCH in
+ * restapi). Resolves once all are done. `onStep(done, total)` (optional) is
+ * called after each PATCH so callers can show progress.
+ */
+export async function patchItems(itemUrls, data, onStep) {
+ let done = 0;
+ for (const url of itemUrls) {
+ await patchItem(url, data);
+ onStep?.(++done, itemUrls.length);
+ }
+}
+
+/**
+ * Sort all items in a folder by a catalog index in one server call, via the
+ * OrderingMixin `sort` deserializer (replaces the legacy `/rearrange` endpoint).
+ * After the call the folder's `getObjPositionInParent` index reflects the new
+ * order, so switching to manual-order mode shows the rearranged listing.
+ *
+ * @param {object} args
+ * @param {string} args.containerUrl
+ * @param {string} args.sortOn - catalog index, e.g. "sortable_title" or "modified"
+ * @param {"ascending"|"descending"} args.sortOrder
+ */
+export function rearrangeFolder({ containerUrl, sortOn, sortOrder }) {
+ return request(containerUrl, {
+ method: "PATCH",
+ body: { sort: { on: sortOn, order: sortOrder } },
+ });
+}
diff --git a/src/pat/filemanager/src/api/operations.test.js b/src/pat/filemanager/src/api/operations.test.js
new file mode 100644
index 000000000..0edcc123f
--- /dev/null
+++ b/src/pat/filemanager/src/api/operations.test.js
@@ -0,0 +1,158 @@
+import {
+ objId,
+ pasteItems,
+ deleteItem,
+ deleteItems,
+ moveItem,
+ setDefaultPage,
+ patchItem,
+ patchItems,
+ rearrangeFolder,
+} from "./operations.js";
+import { request } from "./client.js";
+
+jest.mock("./client.js", () => ({ request: jest.fn() }));
+
+const mockedRequest = request;
+
+beforeEach(() => {
+ mockedRequest.mockReset();
+ mockedRequest.mockResolvedValue(null);
+});
+
+describe("objId", () => {
+ it("returns the last path segment", () => {
+ expect(objId("http://nohost/plone/folder/doc-1")).toBe("doc-1");
+ expect(objId("/plone/folder/doc-1/")).toBe("doc-1");
+ expect(objId("http://nohost/plone/folder/doc-1?foo=1")).toBe("doc-1");
+ expect(objId("")).toBe("");
+ });
+});
+
+describe("pasteItems", () => {
+ it("POSTs to @move for a cut", async () => {
+ await pasteItems({
+ targetUrl: "http://nohost/plone/target",
+ sources: ["http://nohost/plone/a", "http://nohost/plone/b"],
+ op: "cut",
+ });
+ expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/target/@move", {
+ method: "POST",
+ body: { source: ["http://nohost/plone/a", "http://nohost/plone/b"] },
+ });
+ });
+
+ it("POSTs to @copy for a copy", async () => {
+ await pasteItems({
+ targetUrl: "http://nohost/plone/target",
+ sources: ["http://nohost/plone/a"],
+ op: "copy",
+ });
+ expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/target/@copy", {
+ method: "POST",
+ body: { source: ["http://nohost/plone/a"] },
+ });
+ });
+});
+
+describe("deleteItem / deleteItems", () => {
+ it("DELETEs a single item url", async () => {
+ await deleteItem("http://nohost/plone/a");
+ expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/a", { method: "DELETE" });
+ });
+
+ it("DELETEs each item in order", async () => {
+ await deleteItems(["http://nohost/plone/a", "http://nohost/plone/b"]);
+ expect(mockedRequest).toHaveBeenCalledTimes(2);
+ expect(mockedRequest).toHaveBeenNthCalledWith(1, "http://nohost/plone/a", {
+ method: "DELETE",
+ });
+ expect(mockedRequest).toHaveBeenNthCalledWith(2, "http://nohost/plone/b", {
+ method: "DELETE",
+ });
+ });
+});
+
+describe("moveItem", () => {
+ it("PATCHes the container with an ordering payload", async () => {
+ await moveItem({ containerUrl: "http://nohost/plone/folder", id: "doc-1", delta: "top" });
+ expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/folder", {
+ method: "PATCH",
+ body: { ordering: { obj_id: "doc-1", delta: "top" } },
+ });
+ });
+
+ it("includes subset_ids for relative reorders", async () => {
+ await moveItem({
+ containerUrl: "http://nohost/plone/folder",
+ id: "doc-1",
+ delta: 2,
+ subsetIds: ["doc-1", "doc-2", "doc-3"],
+ });
+ const body = mockedRequest.mock.calls[0][1].body;
+ expect(body.ordering).toEqual({
+ obj_id: "doc-1",
+ delta: 2,
+ subset_ids: ["doc-1", "doc-2", "doc-3"],
+ });
+ });
+});
+
+describe("setDefaultPage", () => {
+ it("PATCHes the container with default_page", async () => {
+ await setDefaultPage({ containerUrl: "http://nohost/plone/folder", id: "doc-1" });
+ expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/folder", {
+ method: "PATCH",
+ body: { default_page: "doc-1" },
+ });
+ });
+});
+
+describe("patchItem / patchItems", () => {
+ it("PATCHes one item with the given body", async () => {
+ await patchItem("http://nohost/plone/a", { subjects: ["x"] });
+ expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/a", {
+ method: "PATCH",
+ body: { subjects: ["x"] },
+ });
+ });
+
+ it("PATCHes each item in order with the same body", async () => {
+ await patchItems(["http://nohost/plone/a", "http://nohost/plone/b"], { rights: "r" });
+ expect(mockedRequest).toHaveBeenCalledTimes(2);
+ expect(mockedRequest).toHaveBeenNthCalledWith(1, "http://nohost/plone/a", {
+ method: "PATCH",
+ body: { rights: "r" },
+ });
+ expect(mockedRequest).toHaveBeenNthCalledWith(2, "http://nohost/plone/b", {
+ method: "PATCH",
+ body: { rights: "r" },
+ });
+ });
+});
+
+describe("rearrangeFolder", () => {
+ it("PATCHes the container with a sort payload (ascending)", async () => {
+ await rearrangeFolder({
+ containerUrl: "http://nohost/plone/folder",
+ sortOn: "sortable_title",
+ sortOrder: "ascending",
+ });
+ expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/folder", {
+ method: "PATCH",
+ body: { sort: { on: "sortable_title", order: "ascending" } },
+ });
+ });
+
+ it("PATCHes the container with a sort payload (descending)", async () => {
+ await rearrangeFolder({
+ containerUrl: "http://nohost/plone/folder",
+ sortOn: "modified",
+ sortOrder: "descending",
+ });
+ expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/folder", {
+ method: "PATCH",
+ body: { sort: { on: "modified", order: "descending" } },
+ });
+ });
+});
diff --git a/src/pat/filemanager/src/api/querystring.js b/src/pat/filemanager/src/api/querystring.js
new file mode 100644
index 000000000..ca19de268
--- /dev/null
+++ b/src/pat/filemanager/src/api/querystring.js
@@ -0,0 +1,111 @@
+import { request } from "./client.js";
+
+/**
+ * Fetch the plone.app.querystring registry config via @querystring.
+ *
+ * Replaces the legacy `indexOptionsUrl` filter-widget contract. The reply
+ * contains `indexes` (per-index metadata: title, enabled, operations,
+ * vocabulary `values`, …) and `sortable_indexes`.
+ *
+ * @param {string} contextUrl - absolute url of the folder
+ * @returns {Promise<{indexes: object, sortable_indexes: object}>}
+ */
+export async function fetchQuerystringConfig(contextUrl) {
+ const data = await request(`${contextUrl}/@querystring`);
+ return {
+ indexes: data?.indexes || {},
+ sortable_indexes: data?.sortable_indexes || {},
+ };
+}
+
+/**
+ * Extract the portal_type filter options from a @querystring config.
+ *
+ * The registry reader exposes vocabulary values as
+ * `{ "Document": {title: "Page"}, … }`; flatten to a value/label list.
+ *
+ * @param {{indexes: object}} config
+ * @returns {Array<{value: string, label: string}>}
+ */
+export function typeOptions(config) {
+ const values = config?.indexes?.portal_type?.values || {};
+ return Object.entries(values).map(([value, meta]) => ({
+ value,
+ label: (meta && meta.title) || value,
+ }));
+}
+
+/**
+ * Enabled indexes available to the query builder, as a flat option list with
+ * their `group` for optgroup rendering. Mirrors pat-structure's QueryString
+ * widget, which only offers indexes flagged `enabled` and with operations.
+ *
+ * @param {{indexes: object}} config
+ * @returns {Array<{value: string, title: string, group: string}>}
+ */
+export function enabledIndexes(config) {
+ const indexes = config?.indexes || {};
+ return Object.entries(indexes)
+ .filter(([, meta]) => meta && meta.enabled && (meta.operations || []).length)
+ .map(([value, meta]) => ({
+ value,
+ title: meta.title || value,
+ group: meta.group || "",
+ }));
+}
+
+/**
+ * The operations offered for one index, with their human title and the value
+ * `widget` that decides how the value is edited (StringWidget, DateWidget, …).
+ *
+ * @param {{indexes: object}} config
+ * @param {string} index
+ * @returns {Array<{value: string, title: string, widget: string|null}>}
+ */
+export function operatorsForIndex(config, index) {
+ const meta = config?.indexes?.[index];
+ if (!meta) return [];
+ return (meta.operations || []).map((op) => ({
+ value: op,
+ title: meta.operators?.[op]?.title || op,
+ widget: meta.operators?.[op]?.widget || null,
+ }));
+}
+
+/** The value widget for an index/operation pair (null = no value needed). */
+export function widgetFor(config, index, operation) {
+ return config?.indexes?.[index]?.operators?.[operation]?.widget || null;
+}
+
+/**
+ * The vocabulary value/label pairs for a MultipleSelectionWidget index
+ * (portal_type, review_state, Subject, …).
+ *
+ * @param {{indexes: object}} config
+ * @param {string} index
+ * @returns {Array<{value: string, label: string}>}
+ */
+export function selectionValues(config, index) {
+ const values = config?.indexes?.[index]?.values || {};
+ return Object.entries(values).map(([value, meta]) => ({
+ value,
+ label: (meta && meta.title) || value,
+ }));
+}
+
+/**
+ * Whether a criterion has the value its widget requires. Operations with no
+ * widget (date.today, isTrue, …) are always satisfied; selection/date-range
+ * values are arrays and need at least one entry.
+ *
+ * @param {string|null} widget
+ * @param {unknown} value
+ * @returns {boolean}
+ */
+export function hasValue(widget, value) {
+ if (!widget) return true;
+ if (Array.isArray(value)) {
+ return value.some((v) => v !== "" && v != null);
+ }
+ return value !== "" && value != null;
+}
diff --git a/src/pat/filemanager/src/api/querystring.test.js b/src/pat/filemanager/src/api/querystring.test.js
new file mode 100644
index 000000000..65396335b
--- /dev/null
+++ b/src/pat/filemanager/src/api/querystring.test.js
@@ -0,0 +1,178 @@
+import {
+ fetchQuerystringConfig,
+ typeOptions,
+ enabledIndexes,
+ operatorsForIndex,
+ widgetFor,
+ selectionValues,
+ hasValue,
+} from "./querystring.js";
+import { request } from "./client.js";
+
+jest.mock("./client.js", () => ({ request: jest.fn() }));
+
+const mockedRequest = request;
+
+beforeEach(() => {
+ mockedRequest.mockReset();
+});
+
+describe("fetchQuerystringConfig", () => {
+ it("GETs @querystring and returns indexes + sortable_indexes", async () => {
+ mockedRequest.mockResolvedValue({
+ indexes: { portal_type: { title: "Type" } },
+ sortable_indexes: { sortable_title: {} },
+ });
+ const config = await fetchQuerystringConfig("http://nohost/plone/folder");
+ expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/folder/@querystring");
+ expect(config.indexes.portal_type.title).toBe("Type");
+ expect(config.sortable_indexes.sortable_title).toEqual({});
+ });
+
+ it("falls back to empty objects on a sparse reply", async () => {
+ mockedRequest.mockResolvedValue({});
+ const config = await fetchQuerystringConfig("http://nohost/plone");
+ expect(config.indexes).toEqual({});
+ expect(config.sortable_indexes).toEqual({});
+ });
+});
+
+describe("typeOptions", () => {
+ it("flattens portal_type vocabulary values to value/label pairs", () => {
+ const config = {
+ indexes: {
+ portal_type: {
+ values: {
+ Document: { title: "Page" },
+ Folder: { title: "Folder" },
+ },
+ },
+ },
+ };
+ expect(typeOptions(config)).toEqual([
+ { value: "Document", label: "Page" },
+ { value: "Folder", label: "Folder" },
+ ]);
+ });
+
+ it("falls back to the value when a title is missing", () => {
+ const config = { indexes: { portal_type: { values: { News: {} } } } };
+ expect(typeOptions(config)).toEqual([{ value: "News", label: "News" }]);
+ });
+
+ it("returns an empty list when portal_type has no values", () => {
+ expect(typeOptions({ indexes: {} })).toEqual([]);
+ expect(typeOptions(null)).toEqual([]);
+ });
+});
+
+const sampleConfig = {
+ indexes: {
+ SearchableText: {
+ title: "Text",
+ group: "Text",
+ enabled: true,
+ operations: ["plone.app.querystring.operation.string.contains"],
+ operators: {
+ "plone.app.querystring.operation.string.contains": {
+ title: "Contains",
+ widget: "StringWidget",
+ },
+ },
+ },
+ portal_type: {
+ title: "Type",
+ group: "Metadata",
+ enabled: true,
+ operations: ["plone.app.querystring.operation.selection.any"],
+ operators: {
+ "plone.app.querystring.operation.selection.any": {
+ title: "Any of",
+ widget: "MultipleSelectionWidget",
+ },
+ },
+ values: { Document: { title: "Page" }, Folder: {} },
+ },
+ sortable_title: {
+ title: "Sortable Title",
+ group: "Text",
+ enabled: false,
+ operations: ["plone.app.querystring.operation.string.is"],
+ operators: {},
+ },
+ },
+};
+
+describe("enabledIndexes", () => {
+ it("lists only enabled indexes with operations, keeping their group", () => {
+ expect(enabledIndexes(sampleConfig)).toEqual([
+ { value: "SearchableText", title: "Text", group: "Text" },
+ { value: "portal_type", title: "Type", group: "Metadata" },
+ ]);
+ });
+
+ it("returns an empty list for a missing config", () => {
+ expect(enabledIndexes(null)).toEqual([]);
+ expect(enabledIndexes({ indexes: {} })).toEqual([]);
+ });
+});
+
+describe("operatorsForIndex", () => {
+ it("maps an index's operations to value/title/widget", () => {
+ expect(operatorsForIndex(sampleConfig, "SearchableText")).toEqual([
+ {
+ value: "plone.app.querystring.operation.string.contains",
+ title: "Contains",
+ widget: "StringWidget",
+ },
+ ]);
+ });
+
+ it("returns an empty list for an unknown index", () => {
+ expect(operatorsForIndex(sampleConfig, "nope")).toEqual([]);
+ });
+});
+
+describe("widgetFor", () => {
+ it("resolves the value widget for an index/operation pair", () => {
+ expect(
+ widgetFor(sampleConfig, "portal_type", "plone.app.querystring.operation.selection.any")
+ ).toBe("MultipleSelectionWidget");
+ });
+
+ it("returns null when there is no widget", () => {
+ expect(widgetFor(sampleConfig, "portal_type", "nope")).toBeNull();
+ expect(widgetFor(sampleConfig, "nope", "nope")).toBeNull();
+ });
+});
+
+describe("selectionValues", () => {
+ it("flattens an index vocabulary to value/label pairs", () => {
+ expect(selectionValues(sampleConfig, "portal_type")).toEqual([
+ { value: "Document", label: "Page" },
+ { value: "Folder", label: "Folder" },
+ ]);
+ });
+
+ it("returns an empty list when the index has no vocabulary", () => {
+ expect(selectionValues(sampleConfig, "SearchableText")).toEqual([]);
+ });
+});
+
+describe("hasValue", () => {
+ it("treats a missing widget as always satisfied", () => {
+ expect(hasValue(null, "")).toBe(true);
+ expect(hasValue(null, undefined)).toBe(true);
+ });
+
+ it("requires a non-empty scalar value", () => {
+ expect(hasValue("StringWidget", "")).toBe(false);
+ expect(hasValue("StringWidget", "x")).toBe(true);
+ });
+
+ it("requires at least one entry for array values", () => {
+ expect(hasValue("MultipleSelectionWidget", [])).toBe(false);
+ expect(hasValue("DateRangeWidget", ["", ""])).toBe(false);
+ expect(hasValue("MultipleSelectionWidget", ["news"])).toBe(true);
+ });
+});
diff --git a/src/pat/filemanager/src/api/upload.js b/src/pat/filemanager/src/api/upload.js
new file mode 100644
index 000000000..f9f33ddf9
--- /dev/null
+++ b/src/pat/filemanager/src/api/upload.js
@@ -0,0 +1,177 @@
+import logger from "@patternslib/patternslib/src/core/logging";
+import { request } from "./client.js";
+
+const log = logger.getLogger("pat-filemanager");
+
+// File upload against plone.restapi. Primary path is the resumable @tus-upload
+// service (POST to create the upload, then chunked PATCH of the bytes); a plain
+// content POST (base64-encoded primary field) is the fallback for when tus is
+// unavailable. See spec §3 and plone.restapi services/content/tus.py.
+
+const TUS_VERSION = "1.0.0";
+const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
+
+/** Base64-encode a (UTF-8) string for the Tus Upload-Metadata header. */
+function b64(str) {
+ const bytes = new TextEncoder().encode(str);
+ let bin = "";
+ for (const byte of bytes) bin += String.fromCharCode(byte);
+ return btoa(bin);
+}
+
+/** Build the comma-separated `key b64(value)` Upload-Metadata header value. */
+function encodeMetadata(meta) {
+ return Object.entries(meta)
+ .filter(([, value]) => value !== undefined && value !== null && value !== "")
+ .map(([key, value]) => `${key} ${b64(String(value))}`)
+ .join(",");
+}
+
+/**
+ * Upload one file into a folder via the resumable @tus-upload service.
+ *
+ * POSTs to `{folderUrl}/@tus-upload` to open an upload (the new resource url
+ * comes back in the `Location` header), then PATCHes the bytes in chunks until
+ * the server reports the full length. The created object's url is returned from
+ * the final PATCH `Location` header.
+ *
+ * @param {string} folderUrl - container the file is added to
+ * @param {File} file
+ * @param {object} [opts]
+ * @param {(loaded:number, total:number) => void} [opts.onProgress]
+ * @param {number} [opts.chunkSize]
+ * @param {AbortSignal} [opts.signal]
+ * @returns {Promise} created object url, if the server reported one
+ */
+export async function uploadFileTus(folderUrl, file, opts = {}) {
+ const { onProgress, chunkSize = DEFAULT_CHUNK_SIZE, signal } = opts;
+
+ const metadata = encodeMetadata({
+ filename: file.name,
+ "content-type": file.type || "application/octet-stream",
+ });
+
+ log.debug(`tus POST ${folderUrl}/@tus-upload (${file.size} bytes)`);
+ const createResponse = await fetch(`${folderUrl}/@tus-upload`, {
+ method: "POST",
+ credentials: "same-origin",
+ signal,
+ headers: {
+ Accept: "application/json",
+ "Tus-Resumable": TUS_VERSION,
+ "Upload-Length": String(file.size),
+ "Upload-Metadata": metadata,
+ },
+ });
+ if (!createResponse.ok) {
+ throw new Error(
+ `Could not start upload of ${file.name} (status ${createResponse.status})`
+ );
+ }
+ const location = createResponse.headers.get("Location");
+ if (!location) {
+ throw new Error(`Upload of ${file.name} returned no Location header`);
+ }
+
+ let offset = 0;
+ let createdUrl = null;
+ while (offset < file.size) {
+ const chunk = file.slice(offset, offset + chunkSize);
+ const patchResponse = await fetch(location, {
+ method: "PATCH",
+ credentials: "same-origin",
+ signal,
+ headers: {
+ Accept: "application/json",
+ "Tus-Resumable": TUS_VERSION,
+ "Upload-Offset": String(offset),
+ "Content-Type": "application/offset+octet-stream",
+ },
+ body: chunk,
+ });
+ if (!patchResponse.ok) {
+ throw new Error(
+ `Upload of ${file.name} failed at offset ${offset} (status ${patchResponse.status})`
+ );
+ }
+ const next = Number(patchResponse.headers.get("Upload-Offset"));
+ // Trust the server's reported offset, but never go backwards.
+ offset = Number.isFinite(next) && next > offset ? next : offset + chunk.size;
+ if (onProgress) onProgress(Math.min(offset, file.size), file.size);
+ createdUrl = patchResponse.headers.get("Location") || createdUrl;
+ }
+
+ return createdUrl;
+}
+
+/** Read a File as a bare base64 string (no data: prefix). */
+function fileToBase64(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const result = String(reader.result || "");
+ resolve(result.slice(result.indexOf(",") + 1));
+ };
+ reader.onerror = () => reject(reader.error || new Error("File read failed"));
+ reader.readAsDataURL(file);
+ });
+}
+
+/**
+ * Fallback: create the content with a single JSON POST, sending the file as a
+ * base64 primary field. Images go to an Image, everything else to a File.
+ */
+export async function uploadFilePost(folderUrl, file) {
+ const isImage = (file.type || "").startsWith("image/");
+ const type = isImage ? "Image" : "File";
+ const field = isImage ? "image" : "file";
+ const data = await fileToBase64(file);
+ log.debug(`POST ${folderUrl} (${type} fallback for ${file.name})`);
+ return request(folderUrl, {
+ method: "POST",
+ body: {
+ "@type": type,
+ title: file.name,
+ [field]: {
+ data,
+ encoding: "base64",
+ filename: file.name,
+ "content-type": file.type || "application/octet-stream",
+ },
+ },
+ });
+}
+
+/**
+ * Create an (empty) folderish container inside `parentUrl` via a stock content
+ * POST. `type` is the portal type to create (default "Folder"); restapi derives
+ * the id from the title. Returns the created object — its `@id` is the url
+ * children get added to.
+ *
+ * @param {string} parentUrl - container the folder is created in
+ * @param {object} opts
+ * @param {string} opts.title - folder title (and id source)
+ * @param {string} [opts.type="Folder"] - portal type of the created container
+ * @returns {Promise<{"@id":string}>}
+ */
+export function createFolder(parentUrl, { title, type = "Folder" } = {}) {
+ log.debug(`POST ${parentUrl} (create ${type} "${title}")`);
+ return request(parentUrl, {
+ method: "POST",
+ body: { "@type": type, title },
+ });
+}
+
+/**
+ * Upload one file, preferring resumable tus and falling back to a plain content
+ * POST if tus is unavailable. A failed tus attempt creates no content (the
+ * object is only added once the final chunk lands), so the fallback is safe.
+ */
+export async function uploadFile(folderUrl, file, opts = {}) {
+ try {
+ return await uploadFileTus(folderUrl, file, opts);
+ } catch (error) {
+ log.debug(`tus upload failed, falling back to POST: ${error.message}`);
+ return uploadFilePost(folderUrl, file);
+ }
+}
diff --git a/src/pat/filemanager/src/api/upload.test.js b/src/pat/filemanager/src/api/upload.test.js
new file mode 100644
index 000000000..7abae3770
--- /dev/null
+++ b/src/pat/filemanager/src/api/upload.test.js
@@ -0,0 +1,149 @@
+import { TextEncoder } from "util";
+import { uploadFileTus, uploadFilePost, uploadFile, createFolder } from "./upload.js";
+import { request } from "./client.js";
+
+// jsdom does not expose TextEncoder (browsers and Node do); polyfill it so the
+// tus Upload-Metadata base64 encoding runs.
+if (typeof global.TextEncoder === "undefined") {
+ global.TextEncoder = TextEncoder;
+}
+
+jest.mock("./client.js", () => ({ request: jest.fn() }));
+
+const mockedRequest = request;
+
+/** Minimal Response stand-in with header lookup. */
+function fakeResponse({ ok = true, status = 200, headers = {} } = {}) {
+ return {
+ ok,
+ status,
+ headers: { get: (key) => (key in headers ? headers[key] : null) },
+ };
+}
+
+beforeEach(() => {
+ mockedRequest.mockReset();
+ mockedRequest.mockResolvedValue({ "@id": "http://nohost/plone/folder/a.txt" });
+ global.fetch = jest.fn();
+});
+
+afterEach(() => {
+ delete global.fetch;
+});
+
+describe("uploadFileTus", () => {
+ it("opens an upload then PATCHes the bytes and returns the created url", async () => {
+ const file = new File(["0123456789"], "a.txt", { type: "text/plain" });
+ global.fetch
+ .mockResolvedValueOnce(
+ fakeResponse({
+ status: 201,
+ headers: { Location: "http://nohost/plone/folder/@tus-upload/abc" },
+ })
+ )
+ .mockResolvedValueOnce(
+ fakeResponse({
+ status: 204,
+ headers: {
+ "Upload-Offset": "10",
+ Location: "http://nohost/plone/folder/a.txt",
+ },
+ })
+ );
+
+ const onProgress = jest.fn();
+ const result = await uploadFileTus("http://nohost/plone/folder", file, {
+ onProgress,
+ });
+
+ expect(result).toBe("http://nohost/plone/folder/a.txt");
+
+ const [postUrl, postInit] = global.fetch.mock.calls[0];
+ expect(postUrl).toBe("http://nohost/plone/folder/@tus-upload");
+ expect(postInit.method).toBe("POST");
+ expect(postInit.headers["Upload-Length"]).toBe("10");
+ expect(postInit.headers["Tus-Resumable"]).toBe("1.0.0");
+ expect(postInit.headers["Upload-Metadata"]).toContain("filename ");
+
+ const [patchUrl, patchInit] = global.fetch.mock.calls[1];
+ expect(patchUrl).toBe("http://nohost/plone/folder/@tus-upload/abc");
+ expect(patchInit.method).toBe("PATCH");
+ expect(patchInit.headers["Content-Type"]).toBe("application/offset+octet-stream");
+ expect(patchInit.headers["Upload-Offset"]).toBe("0");
+
+ expect(onProgress).toHaveBeenLastCalledWith(10, 10);
+ });
+
+ it("throws when the create POST fails", async () => {
+ const file = new File(["x"], "a.txt", { type: "text/plain" });
+ global.fetch.mockResolvedValueOnce(fakeResponse({ ok: false, status: 500 }));
+ await expect(uploadFileTus("http://nohost/plone/folder", file)).rejects.toThrow(
+ /Could not start upload/
+ );
+ });
+
+ it("throws when no Location header comes back", async () => {
+ const file = new File(["x"], "a.txt", { type: "text/plain" });
+ global.fetch.mockResolvedValueOnce(fakeResponse({ status: 201 }));
+ await expect(uploadFileTus("http://nohost/plone/folder", file)).rejects.toThrow(
+ /no Location header/
+ );
+ });
+});
+
+describe("uploadFilePost", () => {
+ it("POSTs a base64 File for a non-image", async () => {
+ const file = new File(["abc"], "a.txt", { type: "text/plain" });
+ await uploadFilePost("http://nohost/plone/folder", file);
+ expect(mockedRequest).toHaveBeenCalledTimes(1);
+ const [url, init] = mockedRequest.mock.calls[0];
+ expect(url).toBe("http://nohost/plone/folder");
+ expect(init.method).toBe("POST");
+ expect(init.body["@type"]).toBe("File");
+ expect(init.body.title).toBe("a.txt");
+ expect(init.body.file.encoding).toBe("base64");
+ expect(init.body.file.filename).toBe("a.txt");
+ expect(typeof init.body.file.data).toBe("string");
+ });
+
+ it("POSTs an Image for an image mime type", async () => {
+ const file = new File(["abc"], "pic.png", { type: "image/png" });
+ await uploadFilePost("http://nohost/plone/folder", file);
+ const init = mockedRequest.mock.calls[0][1];
+ expect(init.body["@type"]).toBe("Image");
+ expect(init.body.image).toBeDefined();
+ });
+});
+
+describe("uploadFile", () => {
+ it("falls back to a plain POST when tus fails", async () => {
+ const file = new File(["abc"], "a.txt", { type: "text/plain" });
+ global.fetch.mockResolvedValueOnce(fakeResponse({ ok: false, status: 404 }));
+ await uploadFile("http://nohost/plone/folder", file);
+ expect(mockedRequest).toHaveBeenCalledTimes(1);
+ expect(mockedRequest.mock.calls[0][1].body["@type"]).toBe("File");
+ });
+});
+
+describe("createFolder", () => {
+ it("POSTs a Folder with the given title into the parent", async () => {
+ mockedRequest.mockResolvedValueOnce({ "@id": "http://nohost/plone/folder/sub" });
+ const result = await createFolder("http://nohost/plone/folder", {
+ title: "Sub",
+ });
+ expect(mockedRequest.mock.calls[0][0]).toBe("http://nohost/plone/folder");
+ const init = mockedRequest.mock.calls[0][1];
+ expect(init.method).toBe("POST");
+ expect(init.body).toEqual({ "@type": "Folder", title: "Sub" });
+ expect(result["@id"]).toBe("http://nohost/plone/folder/sub");
+ });
+
+ it("honours a custom folder type", async () => {
+ mockedRequest.mockResolvedValueOnce({ "@id": "x" });
+ await createFolder("http://nohost/plone/folder", {
+ title: "Sub",
+ type: "myfolder",
+ });
+ expect(mockedRequest.mock.calls[0][1].body["@type"]).toBe("myfolder");
+ });
+});
diff --git a/src/pat/filemanager/src/api/vocabularies.js b/src/pat/filemanager/src/api/vocabularies.js
new file mode 100644
index 000000000..db93f27f1
--- /dev/null
+++ b/src/pat/filemanager/src/api/vocabularies.js
@@ -0,0 +1,19 @@
+import { request } from "./client.js";
+
+/**
+ * Fetch a named vocabulary via plone.restapi's @vocabularies service.
+ *
+ * Returns the full term list (b_size=-1 disables batching server-side). Each
+ * term serialises as `{token, title}`. Used for workflow transitions and the
+ * language field in the properties modal.
+ *
+ * @param {string} contextUrl - absolute url of the folder
+ * @param {string} name - vocabulary name (e.g. "plone.app.vocabularies.AvailableContentLanguages")
+ * @returns {Promise>}
+ */
+export async function fetchVocabulary(contextUrl, name) {
+ const data = await request(`${contextUrl}/@vocabularies/${name}`, {
+ params: { b_size: "-1" },
+ });
+ return (data?.items || []).map((t) => ({ token: t.token, title: t.title }));
+}
diff --git a/src/pat/filemanager/src/api/vocabularies.test.js b/src/pat/filemanager/src/api/vocabularies.test.js
new file mode 100644
index 000000000..38aaa2290
--- /dev/null
+++ b/src/pat/filemanager/src/api/vocabularies.test.js
@@ -0,0 +1,39 @@
+import { fetchVocabulary } from "./vocabularies.js";
+import { request } from "./client.js";
+
+jest.mock("./client.js", () => ({ request: jest.fn() }));
+
+const mockedRequest = request;
+
+beforeEach(() => {
+ mockedRequest.mockReset();
+});
+
+describe("fetchVocabulary", () => {
+ it("GETs the vocabulary unbatched and maps terms to {token,title}", async () => {
+ mockedRequest.mockResolvedValue({
+ items: [
+ { token: "en", title: "English" },
+ { token: "de", title: "German" },
+ ],
+ });
+ const terms = await fetchVocabulary(
+ "http://nohost/plone/folder",
+ "plone.app.vocabularies.AvailableContentLanguages"
+ );
+ expect(mockedRequest).toHaveBeenCalledWith(
+ "http://nohost/plone/folder/@vocabularies/plone.app.vocabularies.AvailableContentLanguages",
+ { params: { b_size: "-1" } }
+ );
+ expect(terms).toEqual([
+ { token: "en", title: "English" },
+ { token: "de", title: "German" },
+ ]);
+ });
+
+ it("returns an empty list when the vocabulary has no items", async () => {
+ mockedRequest.mockResolvedValue({});
+ const terms = await fetchVocabulary("http://nohost/plone/folder", "x");
+ expect(terms).toEqual([]);
+ });
+});
diff --git a/src/pat/filemanager/src/api/workflow.js b/src/pat/filemanager/src/api/workflow.js
new file mode 100644
index 000000000..0e275d61b
--- /dev/null
+++ b/src/pat/filemanager/src/api/workflow.js
@@ -0,0 +1,57 @@
+import { request } from "./client.js";
+import { objId } from "./operations.js";
+
+// Workflow operations against plone.restapi's @workflow service. Recursion is
+// server-side: POST {item}/@workflow/{transition} with include_children walks
+// descendants in one call (no client-side @search sweep). See spec §3 / §9.
+
+/** GET the workflow info for one item: `{state, history, transitions:[{@id,title}]}`. */
+export function fetchWorkflow(itemUrl) {
+ return request(`${itemUrl}/@workflow`);
+}
+
+/**
+ * Trigger one transition on one item.
+ *
+ * @param {object} args
+ * @param {string} args.itemUrl
+ * @param {string} args.transition - transition id
+ * @param {string} [args.comment]
+ * @param {boolean} [args.includeChildren] - recurse into descendants (server-side)
+ * @param {string} [args.effective] - optional publication date (ISO)
+ * @param {string} [args.expires] - optional expiration date (ISO)
+ */
+export function transitionItem({
+ itemUrl,
+ transition,
+ comment = "",
+ includeChildren = false,
+ effective,
+ expires,
+}) {
+ const body = { comment, include_children: includeChildren };
+ if (effective !== undefined) body.effective = effective;
+ if (expires !== undefined) body.expires = expires;
+ return request(`${itemUrl}/@workflow/${transition}`, { method: "POST", body });
+}
+
+/**
+ * Collect the union of available transitions across several items, deduped by
+ * transition id (mirrors Volto's batch-workflow dropdown). Items where a chosen
+ * transition is not applicable are tolerated at apply time (the server answers
+ * 400 and the caller records it).
+ *
+ * @param {string[]} itemUrls
+ * @returns {Promise>}
+ */
+export async function fetchTransitions(itemUrls) {
+ const byId = new Map();
+ for (const url of itemUrls) {
+ const wf = await fetchWorkflow(url);
+ for (const t of wf?.transitions || []) {
+ const id = objId(t["@id"]);
+ if (id && !byId.has(id)) byId.set(id, { id, title: t.title || id });
+ }
+ }
+ return [...byId.values()];
+}
diff --git a/src/pat/filemanager/src/api/workflow.test.js b/src/pat/filemanager/src/api/workflow.test.js
new file mode 100644
index 000000000..2bf99a919
--- /dev/null
+++ b/src/pat/filemanager/src/api/workflow.test.js
@@ -0,0 +1,79 @@
+import { fetchWorkflow, transitionItem, fetchTransitions } from "./workflow.js";
+import { request } from "./client.js";
+
+jest.mock("./client.js", () => ({ request: jest.fn() }));
+
+const mockedRequest = request;
+
+beforeEach(() => {
+ mockedRequest.mockReset();
+ mockedRequest.mockResolvedValue(null);
+});
+
+describe("fetchWorkflow", () => {
+ it("GETs the @workflow endpoint for an item", async () => {
+ await fetchWorkflow("http://nohost/plone/doc");
+ expect(mockedRequest).toHaveBeenCalledWith("http://nohost/plone/doc/@workflow");
+ });
+});
+
+describe("transitionItem", () => {
+ it("POSTs the transition with default body", async () => {
+ await transitionItem({ itemUrl: "http://nohost/plone/doc", transition: "publish" });
+ expect(mockedRequest).toHaveBeenCalledWith(
+ "http://nohost/plone/doc/@workflow/publish",
+ { method: "POST", body: { comment: "", include_children: false } }
+ );
+ });
+
+ it("passes comment, include_children and dates when given", async () => {
+ await transitionItem({
+ itemUrl: "http://nohost/plone/doc",
+ transition: "publish",
+ comment: "go live",
+ includeChildren: true,
+ effective: "2026-01-01T00:00",
+ expires: "2026-12-31T00:00",
+ });
+ const body = mockedRequest.mock.calls[0][1].body;
+ expect(body).toEqual({
+ comment: "go live",
+ include_children: true,
+ effective: "2026-01-01T00:00",
+ expires: "2026-12-31T00:00",
+ });
+ });
+});
+
+describe("fetchTransitions", () => {
+ it("unions transitions across items, deduped by id", async () => {
+ mockedRequest
+ .mockResolvedValueOnce({
+ transitions: [
+ { "@id": "http://nohost/plone/a/@workflow/publish", title: "Publish" },
+ { "@id": "http://nohost/plone/a/@workflow/reject", title: "Reject" },
+ ],
+ })
+ .mockResolvedValueOnce({
+ transitions: [
+ { "@id": "http://nohost/plone/b/@workflow/publish", title: "Publish" },
+ { "@id": "http://nohost/plone/b/@workflow/retract", title: "Retract" },
+ ],
+ });
+ const transitions = await fetchTransitions([
+ "http://nohost/plone/a",
+ "http://nohost/plone/b",
+ ]);
+ expect(transitions).toEqual([
+ { id: "publish", title: "Publish" },
+ { id: "reject", title: "Reject" },
+ { id: "retract", title: "Retract" },
+ ]);
+ });
+
+ it("tolerates items without transitions", async () => {
+ mockedRequest.mockResolvedValue({});
+ const transitions = await fetchTransitions(["http://nohost/plone/a"]);
+ expect(transitions).toEqual([]);
+ });
+});
diff --git a/src/pat/filemanager/src/components/BatchActionModal.svelte b/src/pat/filemanager/src/components/BatchActionModal.svelte
new file mode 100644
index 000000000..95b31f17f
--- /dev/null
+++ b/src/pat/filemanager/src/components/BatchActionModal.svelte
@@ -0,0 +1,83 @@
+
+
+
+ {#if modal.active}
+
+ {#if modal.active === "workflow"}
+
+ {:else if modal.active === "tags"}
+
+ {:else if modal.active === "properties"}
+
+ {:else if modal.active === "rename"}
+
+ {:else if modal.active === "rearrange"}
+
+ {:else if modal.active === "linkintegrity"}
+
+ {/if}
+ {/if}
+
diff --git a/src/pat/filemanager/src/components/Breadcrumbs.svelte b/src/pat/filemanager/src/components/Breadcrumbs.svelte
new file mode 100644
index 000000000..b894d9eed
--- /dev/null
+++ b/src/pat/filemanager/src/components/Breadcrumbs.svelte
@@ -0,0 +1,66 @@
+
+
+
+
+
+ navigate(e, home)}>{_t("Home")}
+
+ {#each items as crumb, i (crumb["@id"])}
+
+ navigate(e, crumb["@id"])}>
+ {crumb.title}
+
+
+ {/each}
+
+
diff --git a/src/pat/filemanager/src/components/ColumnCell.svelte b/src/pat/filemanager/src/components/ColumnCell.svelte
new file mode 100644
index 000000000..670776266
--- /dev/null
+++ b/src/pat/filemanager/src/components/ColumnCell.svelte
@@ -0,0 +1,65 @@
+
+
+{#if column.type === "title"}
+
+
+ {value || item.id || item["@id"]}
+ {#if item.exclude_from_nav}
+
+
+
+ {/if}
+
+{:else if column.type === "image"}
+ {#if thumb}
+
+ {:else}
+
+ {/if}
+{:else if column.type === "date"}
+ {formatDate(value)}
+{:else if column.type === "state"}
+ {#if value}
+ {value}
+ {/if}
+{:else if column.type === "tags"}
+ {#each tags as tag (tag)}
+ {tag}
+ {/each}
+{:else if column.key === "getObjSize"}
+ {formatSize(value)}
+{:else}
+ {value ?? ""}
+{/if}
diff --git a/src/pat/filemanager/src/components/ColumnsConfig.svelte b/src/pat/filemanager/src/components/ColumnsConfig.svelte
new file mode 100644
index 000000000..fe355ef67
--- /dev/null
+++ b/src/pat/filemanager/src/components/ColumnsConfig.svelte
@@ -0,0 +1,106 @@
+
+
+ (open = false) }}>
+
(open = !open)}
+ >
+ ⋮
+
+
+ {#if open}
+
+
{_t("Visible columns")}
+
+
+ {#if inactiveDefs.length}
+
{_t("Hidden columns")}
+
+ {/if}
+
+
+ columns.reset()}>{_t("Reset")}
+ (open = false)}>{_t("Done")}
+
+
+ {/if}
+
diff --git a/src/pat/filemanager/src/components/ConfirmDialog.svelte b/src/pat/filemanager/src/components/ConfirmDialog.svelte
new file mode 100644
index 000000000..2c6384bf3
--- /dev/null
+++ b/src/pat/filemanager/src/components/ConfirmDialog.svelte
@@ -0,0 +1,54 @@
+
+
+
+ {#if confirm.isOpen}
+ {confirm.message}
+
+ confirm.cancel()}>
+ {_t("Cancel")}
+
+
+ confirm.confirm()}
+ >
+ {confirm.confirmLabel || _t("OK")}
+
+
+ {/if}
+
diff --git a/src/pat/filemanager/src/components/ContentGrid.svelte b/src/pat/filemanager/src/components/ContentGrid.svelte
new file mode 100644
index 000000000..4d403ad37
--- /dev/null
+++ b/src/pat/filemanager/src/components/ContentGrid.svelte
@@ -0,0 +1,186 @@
+
+
+{#if contents.loading}
+
+ {#each { length: contents.placeholderCount } as _, i (i)}
+
+
+
+
+ {/each}
+
+{:else if contents.error}
+ {contents.error.message}
+{:else}
+
+ {#if contents.parentUrl}
+ {@const parentTask = progress.folderTask(contents.parentUrl)}
+ interactions.onParentDragEnter(e)}
+ ondragover={(e) => interactions.onParentDragOver(e)}
+ ondragleave={() => interactions.onParentDragLeave()}
+ ondrop={(e) => interactions.onParentDrop(e)}
+ >
+
+
+
+
+
+ {_t("Up to parent")}
+
+ {#if parentTask}
+
+
+ {parentTask.label}
+
+
+
+ {/if}
+
+ {/if}
+
+ {#each contents.items as item, index (item.UID || item["@id"])}
+ {@const thumb = thumbnailUrl(item, previewScales)}
+ {@const folderTask = progress.folderTask(item["@id"])}
+ interactions.onCardClick(e, item, index)}
+ onkeydown={(e) => interactions.onItemKeydown(e, item, index)}
+ onmousedown={(e) => interactions.onItemMouseDown(e)}
+ ondragenter={(e) => interactions.onRowDragEnter(e, index)}
+ ondragover={(e) => interactions.onRowDragOver(e, index)}
+ ondrop={(e) => interactions.onRowDrop(e, index)}
+ >
+
+ selection.toggle(item)}
+ aria-label={_t("Select ${name}", { name: item.Title || item["@id"] })}
+ />
+
+
+
+
+ {#if thumb}
+
+ {:else}
+
+
+
+ {/if}
+
+
+ onTitleClick(e, item)}
+ >
+ {item.Title || item.id || item["@id"]}
+
+
+ {#if item.exclude_from_nav}
+
+
+
+ {/if}
+
+ {#if folderTask}
+
+
+ {folderTask.label}
+
+
+
+ {/if}
+
+ {/each}
+
+
+ {#if contents.items.length === 0}
+ {_t("No items in this folder.")}
+ {/if}
+{/if}
diff --git a/src/pat/filemanager/src/components/ContentTable.svelte b/src/pat/filemanager/src/components/ContentTable.svelte
new file mode 100644
index 000000000..5d0c6a480
--- /dev/null
+++ b/src/pat/filemanager/src/components/ContentTable.svelte
@@ -0,0 +1,226 @@
+
+
+
diff --git a/src/pat/filemanager/src/components/FilterBar.svelte b/src/pat/filemanager/src/components/FilterBar.svelte
new file mode 100644
index 000000000..7927a9c4c
--- /dev/null
+++ b/src/pat/filemanager/src/components/FilterBar.svelte
@@ -0,0 +1,96 @@
+
+
+
+ {#if view.mode === "grid"}
+
+ {/if}
+
+
+
+
+ {#if qsConfig}
+
(queryOpen = false) }}
+ >
+
(queryOpen = !queryOpen)}
+ >
+
+ {contents.extraCriteria.length
+ ? _t("Filter (${count})", { count: contents.extraCriteria.length })
+ : _t("Filter")}
+
+ {#if queryOpen}
+
+
+
+ {/if}
+
+ {/if}
+
+
+ {#if contents.hasActiveFilters}
+
{_t("Clear")}
+ {/if}
+
diff --git a/src/pat/filemanager/src/components/FolderDropPreview.svelte b/src/pat/filemanager/src/components/FolderDropPreview.svelte
new file mode 100644
index 000000000..ec65f5f16
--- /dev/null
+++ b/src/pat/filemanager/src/components/FolderDropPreview.svelte
@@ -0,0 +1,111 @@
+
+
+
+ {#if folderDrop.isOpen}
+
+ {_t('Upload folder into "${target}"?', { target: folderDrop.targetName })}
+
+ {summary}
+
+ {#each rows as row, i (i)}
+
+ {row.name}
+ {#if row.files > 0}
+
+ {_t("${count} files", { count: row.files })}
+
+ {/if}
+
+ {/each}
+
+
+ folderDrop.cancel()}>
+ {_t("Cancel")}
+
+
+ folderDrop.approve()}
+ >
+ {_t("Upload")}
+
+
+ {/if}
+
diff --git a/src/pat/filemanager/src/components/GridSizeSlider.svelte b/src/pat/filemanager/src/components/GridSizeSlider.svelte
new file mode 100644
index 000000000..0add58609
--- /dev/null
+++ b/src/pat/filemanager/src/components/GridSizeSlider.svelte
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pat/filemanager/src/components/Icon.svelte b/src/pat/filemanager/src/components/Icon.svelte
new file mode 100644
index 000000000..ead562f74
--- /dev/null
+++ b/src/pat/filemanager/src/components/Icon.svelte
@@ -0,0 +1,22 @@
+
+
+
+
+ {#await svg then markup}
+ {#if markup}{@html markup}{/if}
+ {/await}
+
diff --git a/src/pat/filemanager/src/components/Pagination.svelte b/src/pat/filemanager/src/components/Pagination.svelte
new file mode 100644
index 000000000..bd61d3692
--- /dev/null
+++ b/src/pat/filemanager/src/components/Pagination.svelte
@@ -0,0 +1,84 @@
+
+
+
diff --git a/src/pat/filemanager/src/components/ProgressDialog.svelte b/src/pat/filemanager/src/components/ProgressDialog.svelte
new file mode 100644
index 000000000..70e5fac00
--- /dev/null
+++ b/src/pat/filemanager/src/components/ProgressDialog.svelte
@@ -0,0 +1,54 @@
+
+
+
+
+ {#each progress.dialogTasks as task (task.id)}
+
+ {task.label}
+ {#if task.total > 0}
+
+
+ {task.current} / {task.total}
+
+ {:else}
+
+ {/if}
+
+ {/each}
+
+
diff --git a/src/pat/filemanager/src/components/QueryBuilder.svelte b/src/pat/filemanager/src/components/QueryBuilder.svelte
new file mode 100644
index 000000000..d7e3c4358
--- /dev/null
+++ b/src/pat/filemanager/src/components/QueryBuilder.svelte
@@ -0,0 +1,185 @@
+
+
+
diff --git a/src/pat/filemanager/src/components/QueryBuilder.test.js b/src/pat/filemanager/src/components/QueryBuilder.test.js
new file mode 100644
index 000000000..92e5330c5
--- /dev/null
+++ b/src/pat/filemanager/src/components/QueryBuilder.test.js
@@ -0,0 +1,104 @@
+import { mount, flushSync, tick } from "svelte";
+import QueryBuilder from "./QueryBuilder.svelte";
+
+// Component test for the advanced query builder: adding rows, picking an index /
+// operation, and that complete criteria (and only complete ones) reach onApply
+// as plone.app.querystring {i, o, v} triples. Runs via the custom CJS .svelte
+// transformer (tools/jest-svelte-component.cjs).
+
+const config = {
+ indexes: {
+ SearchableText: {
+ title: "Text",
+ group: "Text",
+ enabled: true,
+ operations: ["op.contains"],
+ operators: { "op.contains": { title: "Contains", widget: "StringWidget" } },
+ },
+ portal_type: {
+ title: "Type",
+ group: "Metadata",
+ enabled: true,
+ operations: ["op.any"],
+ operators: { "op.any": { title: "Any of", widget: "MultipleSelectionWidget" } },
+ values: { Document: { title: "Page" }, Folder: { title: "Folder" } },
+ },
+ },
+};
+
+function render({ criteria = [], onApply = jest.fn() } = {}) {
+ const target = document.createElement("div");
+ document.body.appendChild(target);
+ const inst = mount(QueryBuilder, {
+ target,
+ props: { config, criteria, onApply },
+ });
+ return { target, inst, onApply };
+}
+
+function setSelect(el, value) {
+ el.value = value;
+ el.dispatchEvent(new Event("change", { bubbles: true }));
+ flushSync();
+}
+
+afterEach(() => {
+ document.body.innerHTML = "";
+ jest.clearAllMocks();
+});
+
+describe("QueryBuilder", () => {
+ it("shows an empty hint and an Add criteria button initially", () => {
+ const { target } = render();
+ expect(target.querySelector(".filemanager-querybuilder-empty")).toBeTruthy();
+ expect(target.querySelector(".filemanager-querybuilder-add")).toBeTruthy();
+ });
+
+ it("adds a row and lists the enabled indexes grouped", async () => {
+ const { target } = render();
+ target.querySelector(".filemanager-querybuilder-add").click();
+ flushSync();
+ await tick();
+ const index = target.querySelector(".filemanager-querybuilder-index");
+ expect(index).toBeTruthy();
+ const groups = [...index.querySelectorAll("optgroup")].map((g) => g.label);
+ expect(groups).toEqual(["Text", "Metadata"]);
+ });
+
+ it("does not emit an incomplete criterion (index but empty value)", async () => {
+ const { target, onApply } = render();
+ target.querySelector(".filemanager-querybuilder-add").click();
+ flushSync();
+ await tick();
+ setSelect(target.querySelector(".filemanager-querybuilder-index"), "SearchableText");
+ // index + operation are set, but the StringWidget value is still empty
+ expect(onApply).toHaveBeenLastCalledWith([]);
+ });
+
+ it("emits a complete {i, o, v} criterion once a value is entered", async () => {
+ const { target, onApply } = render();
+ target.querySelector(".filemanager-querybuilder-add").click();
+ flushSync();
+ await tick();
+ setSelect(target.querySelector(".filemanager-querybuilder-index"), "SearchableText");
+ const value = target.querySelector("input.filemanager-querybuilder-value");
+ value.value = "hello";
+ // input updates the bound row.v, change triggers the emit
+ value.dispatchEvent(new Event("input", { bubbles: true }));
+ value.dispatchEvent(new Event("change", { bubbles: true }));
+ flushSync();
+ expect(onApply).toHaveBeenLastCalledWith([
+ { i: "SearchableText", o: "op.contains", v: "hello" },
+ ]);
+ });
+
+ it("seeds rows from incoming criteria and removes them", async () => {
+ const { target, onApply } = render({
+ criteria: [{ i: "SearchableText", o: "op.contains", v: "seed" }],
+ });
+ expect(target.querySelector("input.filemanager-querybuilder-value").value).toBe("seed");
+ target.querySelector(".filemanager-querybuilder-remove").click();
+ flushSync();
+ expect(onApply).toHaveBeenLastCalledWith([]);
+ });
+});
diff --git a/src/pat/filemanager/src/components/RowActionMenu.svelte b/src/pat/filemanager/src/components/RowActionMenu.svelte
new file mode 100644
index 000000000..690f8c8f9
--- /dev/null
+++ b/src/pat/filemanager/src/components/RowActionMenu.svelte
@@ -0,0 +1,164 @@
+
+
+
diff --git a/src/pat/filemanager/src/components/RowActionMenu.test.js b/src/pat/filemanager/src/components/RowActionMenu.test.js
new file mode 100644
index 000000000..04ac3aeb4
--- /dev/null
+++ b/src/pat/filemanager/src/components/RowActionMenu.test.js
@@ -0,0 +1,137 @@
+import { mount, unmount, flushSync, tick } from "svelte";
+import RowActionMenu from "./RowActionMenu.svelte";
+
+// Component test for the row action menu, focused on the keyboard-accessible
+// reorder controls (Move up / Move down / Move to top / Move to bottom) and the
+// conditions under which they are enabled. Runs via the custom CJS .svelte
+// transformer (tools/jest-svelte-component.cjs); svelte-jester cannot run here.
+
+function makeContents(overrides = {}) {
+ return {
+ sortOn: "getObjPositionInParent",
+ items: [{}, {}, {}],
+ currentIds: ["a", "b", "c"],
+ moveTo: jest.fn().mockResolvedValue(undefined),
+ makeDefaultPage: jest.fn().mockResolvedValue(undefined),
+ ...overrides,
+ };
+}
+
+function render({ index = 1, item, contents, clipboard } = {}) {
+ const target = document.createElement("div");
+ document.body.appendChild(target);
+ const context = new Map([
+ ["contents", contents ?? makeContents()],
+ ["clipboard", clipboard ?? { cut: jest.fn(), copy: jest.fn() }],
+ ]);
+ const resolvedItem = item ?? {
+ "@id": "http://nohost/plone/folder/b",
+ Title: "B",
+ UID: "uid-b",
+ };
+ const inst = mount(RowActionMenu, {
+ target,
+ props: { item: resolvedItem, index },
+ context,
+ });
+ return { target, inst, context };
+}
+
+async function open(target) {
+ target.querySelector(".filemanager-rowmenu-toggle").click();
+ flushSync();
+ await tick();
+}
+
+function menuItem(target, label) {
+ return [...target.querySelectorAll('[role="menuitem"]')].find(
+ (el) => el.textContent.trim() === label
+ );
+}
+
+afterEach(() => {
+ document.body.innerHTML = "";
+ jest.clearAllMocks();
+});
+
+describe("RowActionMenu reorder controls", () => {
+ it("disables Move up / Move to top on the first row", async () => {
+ const { target, inst } = render({ index: 0 });
+ await open(target);
+ expect(menuItem(target, "Move up").disabled).toBe(true);
+ expect(menuItem(target, "Move to top").disabled).toBe(true);
+ expect(menuItem(target, "Move down").disabled).toBe(false);
+ expect(menuItem(target, "Move to bottom").disabled).toBe(false);
+ unmount(inst);
+ });
+
+ it("disables Move down / Move to bottom on the last row", async () => {
+ const { target, inst } = render({ index: 2 });
+ await open(target);
+ expect(menuItem(target, "Move down").disabled).toBe(true);
+ expect(menuItem(target, "Move to bottom").disabled).toBe(true);
+ expect(menuItem(target, "Move up").disabled).toBe(false);
+ expect(menuItem(target, "Move to top").disabled).toBe(false);
+ unmount(inst);
+ });
+
+ it("enables all reorder controls on a middle row", async () => {
+ const { target, inst } = render({ index: 1 });
+ await open(target);
+ for (const label of ["Move up", "Move down", "Move to top", "Move to bottom"]) {
+ expect(menuItem(target, label).disabled).toBe(false);
+ }
+ unmount(inst);
+ });
+
+ it("disables every reorder control when not in manual-order mode", async () => {
+ const contents = makeContents({ sortOn: "sortable_title" });
+ const { target, inst } = render({ index: 1, contents });
+ await open(target);
+ for (const label of ["Move up", "Move down", "Move to top", "Move to bottom"]) {
+ expect(menuItem(target, label).disabled).toBe(true);
+ }
+ unmount(inst);
+ });
+
+ it("moves a row up one step within the visible page", async () => {
+ const contents = makeContents();
+ const { target, inst } = render({ index: 1, contents });
+ await open(target);
+ menuItem(target, "Move up").click();
+ flushSync();
+ expect(contents.moveTo).toHaveBeenCalledWith("b", -1, ["a", "b", "c"]);
+ unmount(inst);
+ });
+
+ it("moves a row down one step within the visible page", async () => {
+ const contents = makeContents();
+ const { target, inst } = render({ index: 1, contents });
+ await open(target);
+ menuItem(target, "Move down").click();
+ flushSync();
+ expect(contents.moveTo).toHaveBeenCalledWith("b", 1, ["a", "b", "c"]);
+ unmount(inst);
+ });
+
+ it("uses absolute deltas (no subset) for Move to top / bottom", async () => {
+ const contents = makeContents();
+ const { target, inst } = render({ index: 1, contents });
+ await open(target);
+ menuItem(target, "Move to top").click();
+ flushSync();
+ expect(contents.moveTo).toHaveBeenCalledWith("b", "top");
+ unmount(inst);
+ });
+
+ it("closes the menu after a reorder action", async () => {
+ const { target, inst } = render({ index: 1 });
+ await open(target);
+ expect(target.querySelector('[role="menu"]')).toBeTruthy();
+ menuItem(target, "Move up").click();
+ flushSync();
+ await tick();
+ expect(target.querySelector('[role="menu"]')).toBeNull();
+ unmount(inst);
+ });
+});
diff --git a/src/pat/filemanager/src/components/SelectAll.svelte b/src/pat/filemanager/src/components/SelectAll.svelte
new file mode 100644
index 000000000..213476033
--- /dev/null
+++ b/src/pat/filemanager/src/components/SelectAll.svelte
@@ -0,0 +1,36 @@
+
+
+
+
+ {#if someSelected}
+ {_t("${count} selected", { count: selection.count })}
+ {:else}
+ {_t("Select all")}
+ {/if}
+
diff --git a/src/pat/filemanager/src/components/StatusMessages.svelte b/src/pat/filemanager/src/components/StatusMessages.svelte
new file mode 100644
index 000000000..adb6699c4
--- /dev/null
+++ b/src/pat/filemanager/src/components/StatusMessages.svelte
@@ -0,0 +1,126 @@
+
+
+
+
+{#if status.messages.length || progress.statusTasks.length || upload.entries.length}
+
+ {#each status.messages as message (message.id)}
+
+ {message.text}
+ status.dismiss(message.id)}
+ >
+ ×
+
+
+ {/each}
+
+ {#if progress.statusTasks.length}
+
+
+ {#each progress.statusTasks as task (task.id)}
+
+ {task.label}
+ {#if task.total > 0}
+
+
+ {task.current} / {task.total}
+
+ {:else}
+
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+ {#if upload.entries.length}
+
+ {#if !upload.active}
+
upload.clearFinished()}
+ >
+ ×
+
+ {/if}
+
0}
+ >
+ {uploadSummary}
+
+
+ {#each upload.entries as entry (entry.id)}
+
+ {entry.name}
+ {#if entry.status === "error"}
+ {entry.error}
+ {:else if entry.status === "done"}
+ {_t("done")}
+ {:else}
+
+
+ {formatSize(entry.loaded)} / {formatSize(entry.size)}
+
+ {/if}
+
+ {/each}
+
+
+ {/if}
+
+{/if}
diff --git a/src/pat/filemanager/src/components/Toolbar.svelte b/src/pat/filemanager/src/components/Toolbar.svelte
new file mode 100644
index 000000000..edbff3956
--- /dev/null
+++ b/src/pat/filemanager/src/components/Toolbar.svelte
@@ -0,0 +1,267 @@
+
+
+
+
+
+
+ {#if canSelectAllInQuery}
+
+ {_t("Select all ${count} in query", { count: contents.total })}
+
+ {:else if selection.mode === "all"}
+
{_t("All ${count} in query selected", { count: selection.count })}
+ {/if}
+
+
fileInput.click()}
+ >
+
+ {_t("Upload")}
+
+
+
+
modal.toggle("rearrange")}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ modal.toggle("workflow")}
+ >
+
+
+ modal.toggle("tags")}
+ >
+
+
+ modal.toggle("properties")}
+ >
+
+
+ modal.toggle("rename")}
+ >
+
+
+
+
diff --git a/src/pat/filemanager/src/components/UploadZone.svelte b/src/pat/filemanager/src/components/UploadZone.svelte
new file mode 100644
index 000000000..3c6cf6b7e
--- /dev/null
+++ b/src/pat/filemanager/src/components/UploadZone.svelte
@@ -0,0 +1,69 @@
+
+
+
+ {@render children?.()}
+
+ {#if dragActive && interactions.fileDropIndex < 0}
+
{_t("Drop files to upload")}
+ {/if}
+
diff --git a/src/pat/filemanager/src/components/ViewSwitcher.svelte b/src/pat/filemanager/src/components/ViewSwitcher.svelte
new file mode 100644
index 000000000..d150686ab
--- /dev/null
+++ b/src/pat/filemanager/src/components/ViewSwitcher.svelte
@@ -0,0 +1,27 @@
+
+
+
+ {#each view.available as mode (mode)}
+ view.setMode(mode)}
+ >
+
+
+ {/each}
+
diff --git a/src/pat/filemanager/src/components/modals/LinkIntegrityForm.svelte b/src/pat/filemanager/src/components/modals/LinkIntegrityForm.svelte
new file mode 100644
index 000000000..b9ab965a7
--- /dev/null
+++ b/src/pat/filemanager/src/components/modals/LinkIntegrityForm.svelte
@@ -0,0 +1,78 @@
+
+
+
diff --git a/src/pat/filemanager/src/components/modals/PropertiesForm.svelte b/src/pat/filemanager/src/components/modals/PropertiesForm.svelte
new file mode 100644
index 000000000..9d4d7a617
--- /dev/null
+++ b/src/pat/filemanager/src/components/modals/PropertiesForm.svelte
@@ -0,0 +1,161 @@
+
+
+
diff --git a/src/pat/filemanager/src/components/modals/RearrangeForm.svelte b/src/pat/filemanager/src/components/modals/RearrangeForm.svelte
new file mode 100644
index 000000000..9938f6c64
--- /dev/null
+++ b/src/pat/filemanager/src/components/modals/RearrangeForm.svelte
@@ -0,0 +1,71 @@
+
+
+
+
+ {_t("Sort all items in this folder by a chosen criterion. The new order becomes the manual (drag-and-drop) order.")}
+
+
+
+ {_t("Sort by")}
+
+ {#each SORT_FIELDS as field (field.value)}
+ {field.label}
+ {/each}
+
+
+
+
+ {_t("Order")}
+
+
+ {_t("Ascending (A → Z, oldest first)")}
+
+
+
+ {_t("Descending (Z → A, newest first)")}
+
+
+
+
+ modal.close()}>
+ {_t("Cancel")}
+
+
+ {modal.busy ? _t("Rearranging…") : _t("Rearrange")}
+
+
+
diff --git a/src/pat/filemanager/src/components/modals/RenameForm.svelte b/src/pat/filemanager/src/components/modals/RenameForm.svelte
new file mode 100644
index 000000000..af3adbbc2
--- /dev/null
+++ b/src/pat/filemanager/src/components/modals/RenameForm.svelte
@@ -0,0 +1,87 @@
+
+
+
+
+ {_t("Edit the title and short name (URL segment) of each item.")}
+
+
+
+
+
+ modal.close()}>
+ {_t("Cancel")}
+
+
+ {modal.busy ? _t("Renaming…") : _t("Rename")}
+
+
+
diff --git a/src/pat/filemanager/src/components/modals/TagsForm.svelte b/src/pat/filemanager/src/components/modals/TagsForm.svelte
new file mode 100644
index 000000000..4403abdc1
--- /dev/null
+++ b/src/pat/filemanager/src/components/modals/TagsForm.svelte
@@ -0,0 +1,109 @@
+
+
+
+
+ {_t("Add or remove tags on ${count} selected items.", { count: items.length })}
+
+
+
+ {_t("Tags to add")}
+
+
+
+ {#if currentTags.length}
+
+ {_t("Tags to remove")}
+ {#each currentTags as tag (tag)}
+
+
+ {tag}
+
+ {/each}
+
+ {/if}
+
+
+ modal.close()}>
+ {_t("Cancel")}
+
+
+ {modal.busy ? _t("Saving…") : _t("Save")}
+
+
+
diff --git a/src/pat/filemanager/src/components/modals/WorkflowForm.svelte b/src/pat/filemanager/src/components/modals/WorkflowForm.svelte
new file mode 100644
index 000000000..3596b758c
--- /dev/null
+++ b/src/pat/filemanager/src/components/modals/WorkflowForm.svelte
@@ -0,0 +1,118 @@
+
+
+
+
+ {_t("Apply a transition to ${count} selected items.", { count: items.length })}
+
+
+ {#if loading}
+ {_t("Loading available transitions…")}
+ {:else if loadError}
+ {loadError}
+ {:else if transitions.length === 0}
+
+ {_t("No transitions are available for the selected items.")}
+
+ {:else}
+
+ {_t("Transition")}
+
+ {_t("Select a transition…")}
+ {#each transitions as t (t.id)}
+ {t.title}
+ {/each}
+
+
+
+
+ {_t("Comment")}
+
+
+
+ {#if hasFolders}
+
+
+ {_t("Also apply to contained items (recursive)")}
+
+ {/if}
+ {/if}
+
+
+ modal.close()}>
+ {_t("Cancel")}
+
+
+ {modal.busy ? _t("Applying…") : _t("Apply")}
+
+
+
diff --git a/src/pat/filemanager/src/stores/ClipboardStore.svelte.ts b/src/pat/filemanager/src/stores/ClipboardStore.svelte.ts
new file mode 100644
index 000000000..5e82e83ee
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ClipboardStore.svelte.ts
@@ -0,0 +1,42 @@
+// Client-side cut/copy buffer. The legacy /cut and /copy JSON views are gone;
+// instead the clipboard just records what was marked and which operation, and
+// the paste later issues a stock @move / @copy into the target folder.
+
+export type ClipboardOp = "cut" | "copy";
+
+export interface ClipboardItem {
+ url: string;
+ title: string;
+}
+
+export class ClipboardStore {
+ op = $state(null);
+ items = $state([]);
+
+ get isEmpty(): boolean {
+ return this.items.length === 0;
+ }
+
+ get count(): number {
+ return this.items.length;
+ }
+
+ get sources(): string[] {
+ return this.items.map((it) => it.url);
+ }
+
+ cut(items: ClipboardItem[]): void {
+ this.op = "cut";
+ this.items = [...items];
+ }
+
+ copy(items: ClipboardItem[]): void {
+ this.op = "copy";
+ this.items = [...items];
+ }
+
+ clear(): void {
+ this.op = null;
+ this.items = [];
+ }
+}
diff --git a/src/pat/filemanager/src/stores/ClipboardStore.test.ts b/src/pat/filemanager/src/stores/ClipboardStore.test.ts
new file mode 100644
index 000000000..ca77e7777
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ClipboardStore.test.ts
@@ -0,0 +1,47 @@
+import { ClipboardStore } from "./ClipboardStore.svelte";
+
+const items = [
+ { url: "http://nohost/plone/a", title: "A" },
+ { url: "http://nohost/plone/b", title: "B" },
+];
+
+describe("ClipboardStore", () => {
+ it("starts empty", () => {
+ const clip = new ClipboardStore();
+ expect(clip.isEmpty).toBe(true);
+ expect(clip.op).toBeNull();
+ expect(clip.count).toBe(0);
+ });
+
+ it("records a cut and exposes the source urls", () => {
+ const clip = new ClipboardStore();
+ clip.cut(items);
+ expect(clip.op).toBe("cut");
+ expect(clip.count).toBe(2);
+ expect(clip.sources).toEqual(["http://nohost/plone/a", "http://nohost/plone/b"]);
+ expect(clip.isEmpty).toBe(false);
+ });
+
+ it("records a copy", () => {
+ const clip = new ClipboardStore();
+ clip.copy(items);
+ expect(clip.op).toBe("copy");
+ expect(clip.sources).toHaveLength(2);
+ });
+
+ it("clear empties the buffer", () => {
+ const clip = new ClipboardStore();
+ clip.cut(items);
+ clip.clear();
+ expect(clip.isEmpty).toBe(true);
+ expect(clip.op).toBeNull();
+ });
+
+ it("copies the input array (later mutation does not leak in)", () => {
+ const clip = new ClipboardStore();
+ const input = [...items];
+ clip.cut(input);
+ input.push({ url: "http://nohost/plone/c", title: "C" });
+ expect(clip.count).toBe(2);
+ });
+});
diff --git a/src/pat/filemanager/src/stores/ColumnsStore.svelte.ts b/src/pat/filemanager/src/stores/ColumnsStore.svelte.ts
new file mode 100644
index 000000000..ac4bdc6a9
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ColumnsStore.svelte.ts
@@ -0,0 +1,82 @@
+import { cookieStorage, type KeyValueStore } from "../utils/storage";
+import type { ColumnDef, ConfigStore } from "./ConfigStore.svelte";
+
+// Reactive visible-columns state: which columns are shown and in what order.
+// Initialized from ConfigStore (the pattern's active-columns option), then
+// user toggles/reorders are persisted to a cookie, the same way the legacy
+// pat-structure stored its column config.
+
+export class ColumnsStore {
+ config: ConfigStore;
+ active = $state([]);
+ private storage: KeyValueStore | null;
+
+ constructor(config: ConfigStore, storageKey = "pat-filemanager") {
+ this.config = config;
+ this.storage = storageKey ? cookieStorage(storageKey) : null;
+
+ const saved = this.storage?.get("activeColumns");
+ const restored = Array.isArray(saved) ? this.sanitize(saved as string[]) : [];
+ this.active = restored.length ? restored : [...config.activeColumns];
+ }
+
+ /** Keep only known, available keys and drop duplicates, preserving order. */
+ private sanitize(keys: string[]): string[] {
+ const seen = new Set();
+ return keys.filter((key) => {
+ if (seen.has(key) || !this.config.availableColumns.includes(key)) return false;
+ seen.add(key);
+ return true;
+ });
+ }
+
+ get available(): string[] {
+ return this.config.availableColumns;
+ }
+
+ get inactive(): string[] {
+ return this.available.filter((key) => !this.active.includes(key));
+ }
+
+ /** Active columns resolved to their definitions, in display order. */
+ get columns(): ColumnDef[] {
+ return this.active.map((key) => this.config.column(key));
+ }
+
+ isActive(key: string): boolean {
+ return this.active.includes(key);
+ }
+
+ /** Show/hide a column. Hiding the last visible column is refused. */
+ toggle(key: string): void {
+ if (this.active.includes(key)) {
+ if (this.active.length <= 1) return;
+ this.active = this.active.filter((k) => k !== key);
+ } else if (this.available.includes(key)) {
+ this.active = [...this.active, key];
+ }
+ this.persist();
+ }
+
+ /** Shift an active column by `delta` positions (clamped to the list). */
+ move(key: string, delta: number): void {
+ const from = this.active.indexOf(key);
+ if (from < 0) return;
+ const to = from + delta;
+ if (to < 0 || to >= this.active.length) return;
+ const next = [...this.active];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ this.active = next;
+ this.persist();
+ }
+
+ reset(): void {
+ this.active = [...this.config.activeColumns];
+ this.persist();
+ }
+
+ private persist(): void {
+ this.storage?.set("activeColumns", this.active);
+ }
+}
diff --git a/src/pat/filemanager/src/stores/ColumnsStore.test.ts b/src/pat/filemanager/src/stores/ColumnsStore.test.ts
new file mode 100644
index 000000000..0f177a6de
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ColumnsStore.test.ts
@@ -0,0 +1,88 @@
+import Cookies from "js-cookie";
+import { ConfigStore } from "./ConfigStore.svelte";
+import { ColumnsStore } from "./ColumnsStore.svelte";
+
+function makeConfig() {
+ return new ConfigStore({
+ contextUrl: "http://nohost/plone/folder",
+ activeColumns: ["Title", "review_state", "ModificationDate"],
+ availableColumns: ["image", "Title", "review_state", "ModificationDate", "Subject"],
+ });
+}
+
+beforeEach(() => {
+ for (const name of Object.keys(Cookies.get())) {
+ Cookies.remove(name, { path: "/" });
+ }
+});
+
+describe("ColumnsStore", () => {
+ it("initializes active columns from config", () => {
+ const store = new ColumnsStore(makeConfig(), "");
+ expect(store.active).toEqual(["Title", "review_state", "ModificationDate"]);
+ expect(store.inactive).toEqual(["image", "Subject"]);
+ });
+
+ it("toggle adds an inactive column and removes an active one", () => {
+ const store = new ColumnsStore(makeConfig(), "");
+ store.toggle("Subject");
+ expect(store.active).toContain("Subject");
+ store.toggle("review_state");
+ expect(store.active).not.toContain("review_state");
+ });
+
+ it("refuses to hide the last visible column", () => {
+ const store = new ColumnsStore(makeConfig(), "");
+ store.active = ["Title"];
+ store.toggle("Title");
+ expect(store.active).toEqual(["Title"]);
+ });
+
+ it("ignores toggling unknown keys", () => {
+ const store = new ColumnsStore(makeConfig(), "");
+ store.toggle("does_not_exist");
+ expect(store.active).not.toContain("does_not_exist");
+ });
+
+ it("move reorders within the active list and clamps at the edges", () => {
+ const store = new ColumnsStore(makeConfig(), "");
+ store.move("ModificationDate", -1);
+ expect(store.active).toEqual(["Title", "ModificationDate", "review_state"]);
+ store.move("Title", -1); // already first, no-op
+ expect(store.active).toEqual(["Title", "ModificationDate", "review_state"]);
+ });
+
+ it("reset restores the configured active columns", () => {
+ const store = new ColumnsStore(makeConfig(), "");
+ store.toggle("Subject");
+ store.move("Subject", -3);
+ store.reset();
+ expect(store.active).toEqual(["Title", "review_state", "ModificationDate"]);
+ });
+
+ it("persists to and restores from a cookie", () => {
+ const first = new ColumnsStore(makeConfig(), "pat-filemanager");
+ first.toggle("Subject");
+ const second = new ColumnsStore(makeConfig(), "pat-filemanager");
+ expect(second.active).toContain("Subject");
+ });
+
+ it("drops stale or unavailable keys when restoring", () => {
+ Cookies.set(
+ "pat-filemanager:activeColumns",
+ JSON.stringify(["Title", "gone", "Title"]),
+ { path: "/" }
+ );
+ const store = new ColumnsStore(makeConfig(), "pat-filemanager");
+ expect(store.active).toEqual(["Title"]);
+ });
+
+ it("columns getter resolves keys to definitions in order", () => {
+ const store = new ColumnsStore(makeConfig(), "");
+ expect(store.columns.map((c) => c.key)).toEqual([
+ "Title",
+ "review_state",
+ "ModificationDate",
+ ]);
+ });
+});
diff --git a/src/pat/filemanager/src/stores/ConfigStore.svelte.ts b/src/pat/filemanager/src/stores/ConfigStore.svelte.ts
new file mode 100644
index 000000000..4b0d2aa89
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ConfigStore.svelte.ts
@@ -0,0 +1,85 @@
+// Immutable configuration for a pat-filemanager instance, derived from the
+// pattern options (parser args). Lives in a .svelte.ts module so the rest of
+// the store layer can share a single typed shape; it holds no reactive state.
+
+export type ColumnType = "title" | "text" | "date" | "state" | "tags" | "image";
+
+export interface ColumnDef {
+ /** active-columns key (also the catalog metadata field, unless `field` set) */
+ key: string;
+ /** human label (i18n applied at render time) */
+ label: string;
+ /** catalog metadata column to read off each item (defaults to `key`) */
+ field?: string;
+ /** catalog index to sort on; omit for non-sortable columns */
+ sortIndex?: string;
+ type: ColumnType;
+}
+
+export const COLUMN_DEFS: Record = {
+ image: { key: "image", label: "Preview", field: "image_scales", type: "image" },
+ Title: { key: "Title", label: "Title", sortIndex: "sortable_title", type: "title" },
+ portal_type: { key: "portal_type", label: "Type", sortIndex: "portal_type", type: "text" },
+ review_state: { key: "review_state", label: "State", sortIndex: "review_state", type: "state" },
+ ModificationDate: { key: "ModificationDate", label: "Modified", sortIndex: "modified", type: "date" },
+ CreationDate: { key: "CreationDate", label: "Created", sortIndex: "created", type: "date" },
+ EffectiveDate: { key: "EffectiveDate", label: "Published", sortIndex: "effective", type: "date" },
+ ExpirationDate: { key: "ExpirationDate", label: "Expires", sortIndex: "expires", type: "date" },
+ Subject: { key: "Subject", label: "Tags", type: "tags" },
+ getObjSize: { key: "getObjSize", label: "Size", type: "text" },
+};
+
+const DEFAULT_ACTIVE = ["image", "Title", "review_state", "ModificationDate"];
+const DEFAULT_AVAILABLE = Object.keys(COLUMN_DEFS);
+
+export interface ConfigOptions {
+ contextUrl: string;
+ portalUrl?: string;
+ contextPath?: string;
+ activeColumns?: string[];
+ availableColumns?: string[];
+ portalTypes?: string[];
+ searchIndex?: string;
+ defaultBatchSize?: number;
+ sortOn?: string;
+ sortOrder?: "ascending" | "descending";
+ defaultView?: string;
+ /** Portal type created for folders recreated from an OS folder drop. */
+ folderType?: string;
+}
+
+export class ConfigStore {
+ contextUrl: string;
+ portalUrl: string;
+ contextPath: string;
+ activeColumns: string[];
+ availableColumns: string[];
+ portalTypes: string[];
+ searchIndex: string;
+ defaultBatchSize: number;
+ sortOn: string;
+ sortOrder: "ascending" | "descending";
+ defaultView: string;
+ folderType: string;
+
+ constructor(opts: ConfigOptions) {
+ this.contextUrl = opts.contextUrl.replace(/\/+$/, "");
+ this.portalUrl = (opts.portalUrl || this.contextUrl).replace(/\/+$/, "");
+ this.contextPath = opts.contextPath || new URL(this.contextUrl).pathname.replace(/\/+$/, "");
+ this.activeColumns = opts.activeColumns?.length ? opts.activeColumns : DEFAULT_ACTIVE;
+ this.availableColumns = opts.availableColumns?.length
+ ? opts.availableColumns
+ : DEFAULT_AVAILABLE;
+ this.portalTypes = opts.portalTypes || [];
+ this.searchIndex = opts.searchIndex || "SearchableText";
+ this.defaultBatchSize = opts.defaultBatchSize || 25;
+ this.sortOn = opts.sortOn || "getObjPositionInParent";
+ this.sortOrder = opts.sortOrder || "ascending";
+ this.defaultView = opts.defaultView || "table";
+ this.folderType = opts.folderType || "Folder";
+ }
+
+ column(key: string): ColumnDef {
+ return COLUMN_DEFS[key] || { key, label: key, type: "text" };
+ }
+}
diff --git a/src/pat/filemanager/src/stores/ConfirmStore.svelte.ts b/src/pat/filemanager/src/stores/ConfirmStore.svelte.ts
new file mode 100644
index 000000000..942fda831
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ConfirmStore.svelte.ts
@@ -0,0 +1,50 @@
+// A single, app-wide confirmation prompt rendered as a native (see
+// ConfirmDialog.svelte). `ask()` opens the dialog and resolves to true/false
+// when the user confirms or cancels — an awaitable replacement for the blocking
+// window.confirm, consistent with the batch-action modal.
+
+export interface ConfirmOptions {
+ /** Label for the confirm button (defaults to a generic "OK"). */
+ confirmLabel?: string;
+ /** Render the confirm button as a destructive/danger action. */
+ danger?: boolean;
+}
+
+export class ConfirmStore {
+ message = $state(null);
+ confirmLabel = $state("");
+ danger = $state(false);
+
+ private resolver: ((ok: boolean) => void) | null = null;
+
+ get isOpen(): boolean {
+ return this.message !== null;
+ }
+
+ /** Open the prompt; resolves true on confirm, false on cancel/dismiss. */
+ ask(message: string, options: ConfirmOptions = {}): Promise {
+ // A new prompt supersedes any still-pending one (cancel the old).
+ this.resolver?.(false);
+ this.message = message;
+ this.confirmLabel = options.confirmLabel ?? "";
+ this.danger = Boolean(options.danger);
+ return new Promise((resolve) => {
+ this.resolver = resolve;
+ });
+ }
+
+ private settle(ok: boolean): void {
+ const resolve = this.resolver;
+ this.resolver = null;
+ this.message = null;
+ resolve?.(ok);
+ }
+
+ confirm(): void {
+ this.settle(true);
+ }
+
+ cancel(): void {
+ this.settle(false);
+ }
+}
diff --git a/src/pat/filemanager/src/stores/ConfirmStore.test.ts b/src/pat/filemanager/src/stores/ConfirmStore.test.ts
new file mode 100644
index 000000000..99b590727
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ConfirmStore.test.ts
@@ -0,0 +1,38 @@
+import { ConfirmStore } from "./ConfirmStore.svelte";
+
+describe("ConfirmStore", () => {
+ it("opens with the message/label and resolves true on confirm", async () => {
+ const store = new ConfirmStore();
+ const pending = store.ask("Move 2 items?", { confirmLabel: "Move" });
+ expect(store.isOpen).toBe(true);
+ expect(store.message).toBe("Move 2 items?");
+ expect(store.confirmLabel).toBe("Move");
+ store.confirm();
+ await expect(pending).resolves.toBe(true);
+ expect(store.isOpen).toBe(false);
+ });
+
+ it("resolves false on cancel and closes", async () => {
+ const store = new ConfirmStore();
+ const pending = store.ask("Sure?");
+ store.cancel();
+ await expect(pending).resolves.toBe(false);
+ expect(store.isOpen).toBe(false);
+ });
+
+ it("supersedes a pending prompt, resolving the old one false", async () => {
+ const store = new ConfirmStore();
+ const first = store.ask("First?");
+ const second = store.ask("Second?");
+ expect(store.message).toBe("Second?");
+ await expect(first).resolves.toBe(false);
+ store.confirm();
+ await expect(second).resolves.toBe(true);
+ });
+
+ it("carries the danger flag", () => {
+ const store = new ConfirmStore();
+ store.ask("Delete?", { danger: true });
+ expect(store.danger).toBe(true);
+ });
+});
diff --git a/src/pat/filemanager/src/stores/ContentsStore.svelte.ts b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts
new file mode 100644
index 000000000..f544f3813
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ContentsStore.svelte.ts
@@ -0,0 +1,629 @@
+import jQuery from "jquery";
+import { buildCriteria, buildSubtreeCriteria, searchContents } from "../api/contents.js";
+import {
+ pasteItems,
+ deleteItems,
+ moveItem,
+ setDefaultPage,
+ patchItem,
+ rearrangeFolder,
+} from "../api/operations.js";
+import { transitionItem } from "../api/workflow.js";
+import { cookieStorage, type KeyValueStore } from "../utils/storage";
+import type { ConfigStore } from "./ConfigStore.svelte";
+import type { ProgressFn } from "./ProgressStore.svelte";
+
+/** Minimal shape the batch actions need from a selected item. */
+export interface BatchItem {
+ url: string;
+ title: string;
+ isFolderish: boolean;
+ subjects?: string[];
+}
+
+/** Outcome of a batch operation: how many succeeded and which items failed. */
+export interface BatchResult {
+ ok: number;
+ failed: Array<{ title: string; error: string }>;
+}
+
+// Reactive listing state for one folder view. Sorting and batching are pushed
+// to the catalog (via @querystring-search), so a column sort re-queries and
+// orders the whole result set rather than only the current page.
+
+export interface ContentItem {
+ "@id": string;
+ UID?: string;
+ Title?: string;
+ portal_type?: string;
+ review_state?: string;
+ is_folderish?: boolean;
+ image_scales?: Record;
+ [key: string]: unknown;
+}
+
+export class ContentsStore {
+ config: ConfigStore;
+ private storage: KeyValueStore | null;
+
+ items = $state([]);
+ total = $state(0);
+ loading = $state(false);
+ error = $state(null);
+
+ // The folder currently being browsed. Seeded from config but mutable, so
+ // drilling into a subfolder (or clicking a breadcrumb) re-points every
+ // restapi call without remounting the pattern.
+ contextUrl = $state("");
+ contextPath = $state("");
+
+ bStart = $state(0);
+ bSize = $state(25);
+ sortOn = $state("getObjPositionInParent");
+ sortOrder = $state<"ascending" | "descending">("ascending");
+
+ searchableText = $state("");
+ selectedTypes = $state([]);
+ // Extra plone.app.querystring criteria from the advanced query builder, each
+ // a raw `{i, o, v}` triple appended to the catalog query (see buildCriteria).
+ extraCriteria = $state>([]);
+
+ constructor(config: ConfigStore, storageKey = "pat-filemanager") {
+ this.config = config;
+ this.storage = storageKey ? cookieStorage(storageKey) : null;
+ this.contextUrl = config.contextUrl;
+ this.contextPath = config.contextPath;
+ const savedSize = this.storage?.get("batchSize");
+ this.bSize =
+ typeof savedSize === "number" && savedSize > 0
+ ? savedSize
+ : config.defaultBatchSize;
+ this.sortOn = config.sortOn;
+ this.sortOrder = config.sortOrder;
+ }
+
+ /**
+ * Browse into another folder (or breadcrumb ancestor) without leaving the
+ * SPA: re-point the location, drop filters/paging that were scoped to the
+ * old folder, and reload. The caller is responsible for clearing any
+ * cross-folder selection (selection state lives in SelectionStore).
+ */
+ navigateTo(url: string): Promise {
+ const clean = url.split(/[?#]/)[0].replace(/\/+$/, "");
+ this.contextUrl = clean;
+ this.contextPath = new URL(clean, this.config.contextUrl).pathname.replace(
+ /\/+$/,
+ ""
+ );
+ this.searchableText = "";
+ this.selectedTypes = [];
+ this.extraCriteria = [];
+ this.bStart = 0;
+ // Tell the Plone toolbar to re-render for the new context, the same way
+ // pat-structure does it (toolbar.js listens on body for this event and
+ // appends the portal-root-relative path to data-portal-url).
+ const portalUrl = this.config.portalUrl;
+ const toolbarPath = clean.startsWith(portalUrl)
+ ? clean.slice(portalUrl.length)
+ : new URL(clean, this.config.contextUrl).pathname.replace(/\/+$/, "");
+ jQuery("body").trigger("structure-url-changed", [toolbarPath]);
+ return this.load();
+ }
+
+ /**
+ * Whether the current folder has a parent we may browse up into — true for
+ * any folder below the portal root, false at the root itself (the
+ * filemanager is scoped to the portal, so we never go above portalUrl).
+ */
+ get canGoUp(): boolean {
+ const ctx = this.contextUrl.replace(/\/+$/, "");
+ const portal = this.config.portalUrl.replace(/\/+$/, "");
+ return ctx !== portal && ctx.length > portal.length && ctx.startsWith(portal);
+ }
+
+ /** The parent container url (one level up), or null at the portal root. */
+ get parentUrl(): string | null {
+ if (!this.canGoUp) return null;
+ const ctx = this.contextUrl.replace(/\/+$/, "");
+ const parent = ctx.slice(0, ctx.lastIndexOf("/"));
+ return parent || null;
+ }
+
+ get currentPage(): number {
+ return Math.floor(this.bStart / this.bSize) + 1;
+ }
+
+ get pageCount(): number {
+ return Math.max(1, Math.ceil(this.total / this.bSize));
+ }
+
+ /**
+ * How many skeleton rows/cards the loading state should render. `load()`
+ * keeps the previous page's items until the response arrives, so their
+ * count predicts the next page exactly when paging/sorting/filtering within
+ * a folder — reserving the same space and avoiding layout shift. On a fresh
+ * load (no prior items) fall back to a modest screenful, clamped to the
+ * batch size so we never over-reserve for tiny folders.
+ */
+ get placeholderCount(): number {
+ const known = this.items.length;
+ if (known > 0) return Math.min(known, this.bSize);
+ return Math.min(this.bSize, 8);
+ }
+
+ get hasActiveFilters(): boolean {
+ return (
+ this.searchableText.trim().length > 0 ||
+ this.selectedTypes.length > 0 ||
+ this.extraCriteria.length > 0
+ );
+ }
+
+ /** The querystring criteria for the current filter state. */
+ private buildQuery(): ReturnType {
+ const portalTypes = this.selectedTypes.length
+ ? this.selectedTypes
+ : this.config.portalTypes;
+ return buildCriteria({
+ path: this.contextPath,
+ portalTypes,
+ searchableText: this.searchableText.trim(),
+ searchIndex: this.config.searchIndex,
+ extraCriteria: this.extraCriteria,
+ });
+ }
+
+ async load({ silent = false }: { silent?: boolean } = {}): Promise {
+ // A silent reload reconciles with the server without flipping `loading`,
+ // which would swap the listing for the "Loading…" placeholder and tear
+ // down the keyed rows — killing the row reorder (flip) animation.
+ if (!silent) this.loading = true;
+ this.error = null;
+ try {
+ const criteria = this.buildQuery();
+ const { items, total } = await searchContents({
+ contextUrl: this.contextUrl,
+ criteria,
+ sortOn: this.sortOn,
+ sortOrder: this.sortOrder,
+ bStart: this.bStart,
+ bSize: this.bSize,
+ });
+ this.items = items as ContentItem[];
+ this.total = total;
+ } catch (e) {
+ this.error = e as Error;
+ this.items = [];
+ this.total = 0;
+ } finally {
+ if (!silent) this.loading = false;
+ }
+ }
+
+ /** Toggle/ set the sort column and reload from the first page. */
+ sortBy(sortIndex: string): Promise {
+ if (this.sortOn === sortIndex) {
+ this.sortOrder = this.sortOrder === "ascending" ? "descending" : "ascending";
+ } else {
+ this.sortOn = sortIndex;
+ this.sortOrder = "ascending";
+ }
+ this.bStart = 0;
+ // Silent so the keyed rows stay mounted while the re-sorted page arrives:
+ // the items that remain on the page flip from their old slot to the new
+ // one (sortable-style), instead of being torn down behind a "Loading…"
+ // placeholder and remounted with no movement to animate.
+ return this.load({ silent: true });
+ }
+
+ goToPage(page: number): Promise {
+ const target = Math.min(Math.max(1, page), this.pageCount);
+ this.bStart = (target - 1) * this.bSize;
+ return this.load();
+ }
+
+ setBatchSize(size: number): Promise {
+ this.bSize = size;
+ this.bStart = 0;
+ this.storage?.set("batchSize", size);
+ return this.load();
+ }
+
+ /** Update one or more filters and reload from the first page. */
+ applyFilters({
+ searchableText,
+ selectedTypes,
+ extraCriteria,
+ }: {
+ searchableText?: string;
+ selectedTypes?: string[];
+ extraCriteria?: Array<{ i: string; o: string; v?: unknown }>;
+ }): Promise {
+ if (searchableText !== undefined) this.searchableText = searchableText;
+ if (selectedTypes !== undefined) this.selectedTypes = selectedTypes;
+ if (extraCriteria !== undefined) this.extraCriteria = extraCriteria;
+ this.bStart = 0;
+ return this.load();
+ }
+
+ clearFilters(): Promise {
+ this.searchableText = "";
+ this.selectedTypes = [];
+ this.extraCriteria = [];
+ this.bStart = 0;
+ return this.load();
+ }
+
+ /** The object id (last path segment) of a content url. */
+ private objIdOf(url: string): string {
+ return url.split(/[?#]/)[0].replace(/\/+$/, "").split("/").pop() || "";
+ }
+
+ /** Object ids of the currently shown page, in display order. */
+ get currentIds(): string[] {
+ return this.items.map((it) => this.objIdOf(it["@id"]));
+ }
+
+ /**
+ * Page through the whole current query (ignoring batching) and return every
+ * matching item. Used by the "select all in query" sweep; defaults to a
+ * UID-only projection to keep the payload small.
+ */
+ async fetchAllMatching(metadataFields = ["UID"]): Promise {
+ const criteria = this.buildQuery();
+ const pageSize = 1000;
+ const all: ContentItem[] = [];
+ let bStart = 0;
+ // Loop until we've collected the reported total (or a page comes back empty).
+ for (;;) {
+ const { items, total } = await searchContents({
+ contextUrl: this.contextUrl,
+ criteria,
+ sortOn: this.sortOn,
+ sortOrder: this.sortOrder,
+ bStart,
+ bSize: pageSize,
+ limit: 1_000_000,
+ metadataFields,
+ });
+ all.push(...(items as ContentItem[]));
+ bStart += pageSize;
+ if (items.length === 0 || all.length >= total) break;
+ }
+ return all;
+ }
+
+ /** Reload the listing, stepping back a page if the current one emptied out. */
+ private async reloadAfterMutation(): Promise {
+ await this.load();
+ if (this.bStart > 0 && this.items.length === 0) {
+ await this.goToPage(this.pageCount);
+ }
+ }
+
+ /** Paste the clipboard into this folder via @move (cut) / @copy (copy). */
+ async paste(op: "cut" | "copy", sources: string[]): Promise {
+ await pasteItems({ targetUrl: this.contextUrl, sources, op });
+ await this.reloadAfterMutation();
+ }
+
+ /**
+ * Move items into a different folder (drag-into-folder) via @move, then
+ * reload. `targetUrl` is the destination container; `sources` the dragged
+ * item urls (a single row or the whole current selection).
+ */
+ async moveIntoFolder(targetUrl: string, sources: string[]): Promise {
+ await pasteItems({ targetUrl, sources, op: "cut" });
+ await this.reloadAfterMutation();
+ }
+
+ /** Delete the given item urls, then reload. */
+ async removeItems(urls: string[], onProgress?: ProgressFn): Promise {
+ await deleteItems(urls, onProgress);
+ await this.reloadAfterMutation();
+ }
+
+ /**
+ * Reorder one item within the visible page, optimistically. We splice the
+ * item into its new slot first so the keyed rows animate (flip) immediately,
+ * then PATCH the server and reconcile with a silent reload. The optimistic
+ * order already matches what the server produces for relative/`subset_ids`
+ * moves, so the reconcile is a no-op visually; on failure we restore truth.
+ */
+ async moveTo(
+ id: string,
+ delta: "top" | "bottom" | number,
+ subsetIds?: string[]
+ ): Promise {
+ this.reorderLocally(id, delta);
+ try {
+ await moveItem({ containerUrl: this.contextUrl, id, delta, subsetIds });
+ } catch (e) {
+ await this.load();
+ throw e;
+ }
+ await this.load({ silent: true });
+ }
+
+ /**
+ * Live drag-preview reorder: move the item with `id` to `toIndex` in the
+ * visible page, no server call. Mutating the keyed array makes the displaced
+ * rows flip out of the way under the cursor (sortable-style), so the listing
+ * shows where the drop will land while the drag is still in progress.
+ * `commitReorder` persists whatever order the preview left behind.
+ */
+ movePreview(id: string, toIndex: number): void {
+ const from = this.items.findIndex((it) => this.objIdOf(it["@id"]) === id);
+ if (from < 0) return;
+ const to = Math.max(0, Math.min(this.items.length - 1, toIndex));
+ if (to === from) return;
+ const next = [...this.items];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ this.items = next;
+ }
+
+ /**
+ * Commit a drag-reorder whose new order is already reflected in `items` (the
+ * live `movePreview` calls moved the rows as the user dragged). Unlike
+ * `moveTo` we do NOT splice again — we only PATCH the server and reconcile.
+ * `subsetIds` is the server order snapshotted at drag start; `delta` is the
+ * dragged item's net shift within it. On failure we reload to undo the
+ * preview and restore the authoritative order.
+ */
+ async commitReorder(id: string, delta: number, subsetIds: string[]): Promise {
+ if (delta === 0) return;
+ try {
+ await moveItem({ containerUrl: this.contextUrl, id, delta, subsetIds });
+ } catch (e) {
+ await this.load();
+ throw e;
+ }
+ await this.load({ silent: true });
+ }
+
+ /**
+ * Live drag-preview for a contiguous block of object-ids (a multi-row
+ * selection dragged as one): lift the whole run out and re-insert it so its
+ * first item lands at `toIndex`, keeping the rows' relative order. Mirrors
+ * `movePreview` but for several rows; `commitReorderBlock` persists it.
+ */
+ movePreviewBlock(blockObjIds: string[], toIndex: number): void {
+ const from = this.items.findIndex(
+ (it) => this.objIdOf(it["@id"]) === blockObjIds[0]
+ );
+ if (from < 0) return;
+ const k = blockObjIds.length;
+ const start = Math.max(0, Math.min(this.items.length - k, toIndex));
+ if (start === from) return;
+ const next = [...this.items];
+ const block = next.splice(from, k);
+ next.splice(start, 0, ...block);
+ this.items = next;
+ }
+
+ /**
+ * Commit a block reorder. The restapi ordering endpoint only moves one
+ * object per PATCH (and re-validates `subset_ids` against the live server
+ * order each time), so we replay the block as a short sequence of single
+ * moves against a working copy of the server order — placing each row at its
+ * final consecutive slot. Rows are placed from the trailing edge inward
+ * (bottom-up when moving down, top-down when moving up) so already-placed
+ * rows aren't disturbed. `subsetIds` is the order snapshotted at drag start.
+ */
+ async commitReorderBlock(
+ blockObjIds: string[],
+ finalStart: number,
+ subsetIds: string[]
+ ): Promise {
+ const working = [...subsetIds];
+ const origStart = working.indexOf(blockObjIds[0]);
+ if (origStart < 0 || finalStart < 0 || finalStart === origStart) return;
+ const movingDown = finalStart > origStart;
+ const order = blockObjIds.map((_, j) => j);
+ if (movingDown) order.reverse();
+ try {
+ for (const j of order) {
+ const id = blockObjIds[j];
+ const current = working.indexOf(id);
+ const target = finalStart + j;
+ const delta = target - current;
+ if (delta === 0) continue;
+ await moveItem({
+ containerUrl: this.contextUrl,
+ id,
+ delta,
+ subsetIds: [...working],
+ });
+ working.splice(current, 1);
+ working.splice(target, 0, id);
+ }
+ } catch (e) {
+ await this.load();
+ throw e;
+ }
+ await this.load({ silent: true });
+ }
+
+ /** Move an item to its new slot within `items` (mirrors the server reorder). */
+ private reorderLocally(id: string, delta: "top" | "bottom" | number): void {
+ const from = this.items.findIndex((it) => this.objIdOf(it["@id"]) === id);
+ if (from < 0) return;
+ const last = this.items.length - 1;
+ const raw =
+ delta === "top" ? 0 : delta === "bottom" ? last : from + delta;
+ const to = Math.max(0, Math.min(last, raw));
+ if (to === from) return;
+ const next = [...this.items];
+ const [moved] = next.splice(from, 1);
+ next.splice(to, 0, moved);
+ this.items = next;
+ }
+
+ /** Set one child as this folder's default page. */
+ async makeDefaultPage(id: string): Promise {
+ await setDefaultPage({ containerUrl: this.contextUrl, id });
+ }
+
+ /**
+ * Sort all items in the folder by a catalog index in one server call
+ * (replaces the legacy `/rearrange` endpoint). The `sort` key in the PATCH
+ * body drives the OrderingMixin's full-resort path. After the call the
+ * folder's `getObjPositionInParent` index reflects the new order, so the
+ * listing switches to manual-order mode and reloads with the rearranged
+ * items at the top of page 1.
+ */
+ async rearrange(
+ sortOn: string,
+ sortOrder: "ascending" | "descending"
+ ): Promise {
+ await rearrangeFolder({ containerUrl: this.contextUrl, sortOn, sortOrder });
+ this.sortOn = "getObjPositionInParent";
+ this.sortOrder = "ascending";
+ this.bStart = 0;
+ await this.load();
+ }
+
+ /**
+ * Every descendant url beneath an item (excludes the item itself, which
+ * @querystring-search drops as the context UID). Used by recursive
+ * properties; workflow recursion is handled server-side instead.
+ */
+ private async fetchDescendantUrls(itemUrl: string): Promise {
+ const path = new URL(itemUrl, this.config.contextUrl).pathname.replace(/\/+$/, "");
+ const criteria = buildSubtreeCriteria(path);
+ const pageSize = 1000;
+ const urls: string[] = [];
+ let bStart = 0;
+ for (;;) {
+ const { items, total } = await searchContents({
+ contextUrl: itemUrl,
+ criteria,
+ bStart,
+ bSize: pageSize,
+ limit: 1_000_000,
+ metadataFields: ["UID"],
+ });
+ urls.push(...(items as ContentItem[]).map((it) => it["@id"]));
+ bStart += pageSize;
+ if (items.length === 0 || urls.length >= total) break;
+ }
+ return urls;
+ }
+
+ /**
+ * Apply a workflow transition to each item, then reload. Recursion is
+ * server-side (`include_children`). Items where the transition is not
+ * applicable answer 400 and are recorded as failures rather than aborting.
+ */
+ async applyWorkflow(
+ items: BatchItem[],
+ opts: { transition: string; comment?: string; includeChildren?: boolean },
+ onProgress?: ProgressFn
+ ): Promise {
+ const failed: BatchResult["failed"] = [];
+ let done = 0;
+ for (const it of items) {
+ try {
+ await transitionItem({
+ itemUrl: it.url,
+ transition: opts.transition,
+ comment: opts.comment,
+ includeChildren: opts.includeChildren,
+ });
+ } catch (e) {
+ failed.push({ title: it.title, error: (e as Error).message });
+ }
+ onProgress?.(++done, items.length);
+ }
+ await this.load();
+ return { ok: items.length - failed.length, failed };
+ }
+
+ /**
+ * Add/remove tags per item (Volto semantics: the new subject set is the
+ * item's existing subjects minus `remove` plus `add`), then reload.
+ */
+ async applyTags(
+ items: BatchItem[],
+ { add = [], remove = [] }: { add?: string[]; remove?: string[] },
+ onProgress?: ProgressFn
+ ): Promise {
+ const failed: BatchResult["failed"] = [];
+ let done = 0;
+ for (const it of items) {
+ const subjects = [
+ ...new Set(
+ (it.subjects || []).filter((s) => !remove.includes(s)).concat(add)
+ ),
+ ];
+ try {
+ await patchItem(it.url, { subjects });
+ } catch (e) {
+ failed.push({ title: it.title, error: (e as Error).message });
+ }
+ onProgress?.(++done, items.length);
+ }
+ await this.load();
+ return { ok: items.length - failed.length, failed };
+ }
+
+ /**
+ * PATCH a set of metadata properties onto each item, optionally recursing
+ * into every descendant of folderish items, then reload.
+ */
+ async applyProperties(
+ items: BatchItem[],
+ props: Record,
+ recursive = false,
+ onProgress?: ProgressFn
+ ): Promise {
+ const failed: BatchResult["failed"] = [];
+ let done = 0;
+ for (const it of items) {
+ try {
+ await patchItem(it.url, props);
+ } catch (e) {
+ failed.push({ title: it.title, error: (e as Error).message });
+ onProgress?.(++done, items.length);
+ continue;
+ }
+ if (recursive && it.isFolderish) {
+ const descendants = await this.fetchDescendantUrls(it.url);
+ for (const url of descendants) {
+ try {
+ await patchItem(url, props);
+ } catch (e) {
+ failed.push({ title: url, error: (e as Error).message });
+ }
+ }
+ }
+ onProgress?.(++done, items.length);
+ }
+ await this.load();
+ return { ok: items.length - failed.length, failed };
+ }
+
+ /**
+ * Rename items (Volto-style: PATCH `{id, title}` per item), then reload.
+ * NOTE: this is sequential and non-atomic — see spec §14 for the caveat and
+ * the recommended plone.restapi bulk-rename improvement.
+ */
+ async renameItems(
+ renames: Array<{ url: string; id: string; title: string }>,
+ onProgress?: ProgressFn
+ ): Promise {
+ const failed: BatchResult["failed"] = [];
+ let done = 0;
+ for (const r of renames) {
+ try {
+ await patchItem(r.url, { id: r.id, title: r.title });
+ } catch (e) {
+ failed.push({ title: r.title, error: (e as Error).message });
+ }
+ onProgress?.(++done, renames.length);
+ }
+ await this.load();
+ return { ok: renames.length - failed.length, failed };
+ }
+}
diff --git a/src/pat/filemanager/src/stores/ContentsStore.test.ts b/src/pat/filemanager/src/stores/ContentsStore.test.ts
new file mode 100644
index 000000000..681f2405c
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ContentsStore.test.ts
@@ -0,0 +1,707 @@
+import $ from "jquery";
+import Cookies from "js-cookie";
+import { ConfigStore } from "./ConfigStore.svelte";
+import { ContentsStore } from "./ContentsStore.svelte";
+import { buildCriteria, searchContents } from "../api/contents.js";
+import {
+ pasteItems,
+ deleteItems,
+ moveItem,
+ setDefaultPage,
+ patchItem,
+ rearrangeFolder,
+} from "../api/operations.js";
+import { transitionItem } from "../api/workflow.js";
+
+jest.mock("../api/contents.js", () => ({
+ buildCriteria: jest.fn(() => [{ i: "path", o: "op", v: "/plone/folder::1" }]),
+ buildSubtreeCriteria: jest.fn((path: string) => [{ i: "path", o: "op", v: path }]),
+ searchContents: jest.fn(),
+}));
+
+jest.mock("../api/operations.js", () => ({
+ pasteItems: jest.fn().mockResolvedValue(undefined),
+ deleteItems: jest.fn().mockResolvedValue(undefined),
+ moveItem: jest.fn().mockResolvedValue(undefined),
+ setDefaultPage: jest.fn().mockResolvedValue(undefined),
+ patchItem: jest.fn().mockResolvedValue(undefined),
+ rearrangeFolder: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock("../api/workflow.js", () => ({
+ transitionItem: jest.fn().mockResolvedValue(undefined),
+}));
+
+const mockedSearch = searchContents as jest.Mock;
+const mockedBuild = buildCriteria as jest.Mock;
+const mockedPaste = pasteItems as jest.Mock;
+const mockedDelete = deleteItems as jest.Mock;
+const mockedMove = moveItem as jest.Mock;
+const mockedDefaultPage = setDefaultPage as jest.Mock;
+const mockedPatch = patchItem as jest.Mock;
+const mockedRearrange = rearrangeFolder as jest.Mock;
+const mockedTransition = transitionItem as jest.Mock;
+
+function makeStore() {
+ const config = new ConfigStore({
+ contextUrl: "http://nohost/plone/folder",
+ defaultBatchSize: 10,
+ });
+ return new ContentsStore(config);
+}
+
+beforeEach(() => {
+ for (const name of Object.keys(Cookies.get())) {
+ Cookies.remove(name, { path: "/" });
+ }
+ mockedSearch.mockReset();
+ mockedBuild.mockClear();
+ mockedPaste.mockClear();
+ mockedDelete.mockClear();
+ mockedMove.mockClear();
+ mockedDefaultPage.mockClear();
+ mockedPatch.mockClear();
+ mockedPatch.mockResolvedValue(undefined);
+ mockedRearrange.mockClear();
+ mockedTransition.mockClear();
+ mockedTransition.mockResolvedValue(undefined);
+});
+
+describe("ContentsStore", () => {
+ it("seeds batch size and sort from config", () => {
+ const config = new ConfigStore({
+ contextUrl: "http://nohost/plone/folder",
+ defaultBatchSize: 50,
+ sortOn: "effective",
+ sortOrder: "descending",
+ });
+ const store = new ContentsStore(config);
+ expect(store.bSize).toBe(50);
+ expect(store.sortOn).toBe("effective");
+ expect(store.sortOrder).toBe("descending");
+ });
+
+ it("canGoUp / parentUrl reflect the folder's position below the portal root", () => {
+ const config = new ConfigStore({
+ contextUrl: "http://nohost/plone/folder",
+ portalUrl: "http://nohost/plone",
+ });
+ const store = new ContentsStore(config);
+ // One level down: parent is the portal root.
+ expect(store.canGoUp).toBe(true);
+ expect(store.parentUrl).toBe("http://nohost/plone");
+ // Deeper: parent is the folder one level up (trailing slash ignored).
+ store.contextUrl = "http://nohost/plone/folder/sub/";
+ expect(store.parentUrl).toBe("http://nohost/plone/folder");
+ // At the portal root: no parent.
+ store.contextUrl = "http://nohost/plone";
+ expect(store.canGoUp).toBe(false);
+ expect(store.parentUrl).toBeNull();
+ });
+
+ it("loads items and total, toggling loading", async () => {
+ mockedSearch.mockResolvedValue({
+ items: [{ UID: "a" }, { UID: "b" }],
+ total: 2,
+ });
+ const store = makeStore();
+ const pending = store.load();
+ expect(store.loading).toBe(true);
+ await pending;
+ expect(store.loading).toBe(false);
+ expect(store.items).toHaveLength(2);
+ expect(store.total).toBe(2);
+ expect(mockedSearch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ contextUrl: "http://nohost/plone/folder",
+ bStart: 0,
+ bSize: 10,
+ })
+ );
+ });
+
+ it("captures errors and clears the listing", async () => {
+ mockedSearch.mockRejectedValue(new Error("boom"));
+ const store = makeStore();
+ await store.load();
+ expect(store.error).toBeInstanceOf(Error);
+ expect(store.error?.message).toBe("boom");
+ expect(store.items).toEqual([]);
+ expect(store.total).toBe(0);
+ expect(store.loading).toBe(false);
+ });
+
+ it("sortBy sets ascending on a new column and resets the page", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ store.bStart = 30;
+ await store.sortBy("modified");
+ expect(store.sortOn).toBe("modified");
+ expect(store.sortOrder).toBe("ascending");
+ expect(store.bStart).toBe(0);
+ });
+
+ it("sortBy toggles order when re-clicking the same column", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ await store.sortBy("modified");
+ await store.sortBy("modified");
+ expect(store.sortOrder).toBe("descending");
+ });
+
+ it("sortBy reloads silently, never toggling loading", async () => {
+ // The silent reload keeps the keyed rows mounted so the re-sorted page
+ // flips into place instead of remounting behind a "Loading…" placeholder.
+ mockedSearch.mockResolvedValue({
+ items: [{ UID: "a", "@id": "http://nohost/plone/folder/a" }],
+ total: 1,
+ });
+ const store = makeStore();
+ await store.load();
+ const pending = store.sortBy("modified");
+ expect(store.loading).toBe(false);
+ await pending;
+ expect(store.loading).toBe(false);
+ });
+
+ it("computes page count and clamps goToPage", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ store.total = 25; // 3 pages at bSize 10
+ expect(store.pageCount).toBe(3);
+ await store.goToPage(99);
+ expect(store.currentPage).toBe(3);
+ expect(store.bStart).toBe(20);
+ });
+
+ it("setBatchSize resets to the first page", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ store.bStart = 40;
+ await store.setBatchSize(25);
+ expect(store.bSize).toBe(25);
+ expect(store.bStart).toBe(0);
+ });
+
+ it("persists the batch size to a cookie and restores it on a new store", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const config = new ConfigStore({
+ contextUrl: "http://nohost/plone/folder",
+ defaultBatchSize: 10,
+ });
+ const first = new ContentsStore(config, "pat-filemanager");
+ await first.setBatchSize(50);
+
+ const second = new ContentsStore(config, "pat-filemanager");
+ expect(second.bSize).toBe(50);
+ });
+
+ it("navigateTo re-points the folder, resets filters/page and reloads", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ store.bStart = 30;
+ store.searchableText = "old";
+ store.selectedTypes = ["Document"];
+
+ await store.navigateTo("http://nohost/plone/folder/sub/?foo=bar");
+
+ expect(store.contextUrl).toBe("http://nohost/plone/folder/sub");
+ expect(store.contextPath).toBe("/plone/folder/sub");
+ expect(store.searchableText).toBe("");
+ expect(store.selectedTypes).toEqual([]);
+ expect(store.bStart).toBe(0);
+ expect(mockedBuild).toHaveBeenLastCalledWith(
+ expect.objectContaining({ path: "/plone/folder/sub" })
+ );
+ expect(mockedSearch).toHaveBeenLastCalledWith(
+ expect.objectContaining({ contextUrl: "http://nohost/plone/folder/sub" })
+ );
+ });
+
+ it("navigateTo fires structure-url-changed for the toolbar with the portal-relative path", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const config = new ConfigStore({
+ contextUrl: "http://nohost/plone/folder",
+ portalUrl: "http://nohost/plone",
+ });
+ const store = new ContentsStore(config);
+
+ const handler = jest.fn();
+ $("body").on("structure-url-changed", handler);
+ try {
+ await store.navigateTo("http://nohost/plone/folder/sub/?foo=bar");
+ } finally {
+ $("body").off("structure-url-changed", handler);
+ }
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler.mock.calls[0][1]).toBe("/folder/sub");
+ });
+
+ it("applyFilters feeds trimmed search text and types into the criteria", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ store.bStart = 30;
+ await store.applyFilters({ searchableText: " hello ", selectedTypes: ["Document"] });
+ expect(store.bStart).toBe(0);
+ expect(store.hasActiveFilters).toBe(true);
+ expect(mockedBuild).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ searchableText: "hello",
+ portalTypes: ["Document"],
+ })
+ );
+ });
+
+ it("falls back to config portalTypes when no types are selected", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const config = new ConfigStore({
+ contextUrl: "http://nohost/plone/folder",
+ portalTypes: ["News Item"],
+ });
+ const store = new ContentsStore(config);
+ await store.load();
+ expect(mockedBuild).toHaveBeenLastCalledWith(
+ expect.objectContaining({ portalTypes: ["News Item"] })
+ );
+ });
+
+ it("applyFilters feeds advanced extraCriteria into the catalog query", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ const extra = [{ i: "Subject", o: "op.selection.any", v: ["news"] }];
+ store.bStart = 30;
+ await store.applyFilters({ extraCriteria: extra });
+ expect(store.bStart).toBe(0);
+ expect(store.hasActiveFilters).toBe(true);
+ expect(mockedBuild).toHaveBeenLastCalledWith(
+ expect.objectContaining({ extraCriteria: extra })
+ );
+ });
+
+ it("clearFilters resets search, types, extraCriteria and page", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ await store.applyFilters({
+ searchableText: "x",
+ selectedTypes: ["Folder"],
+ extraCriteria: [{ i: "Subject", o: "op", v: ["a"] }],
+ });
+ await store.clearFilters();
+ expect(store.searchableText).toBe("");
+ expect(store.selectedTypes).toEqual([]);
+ expect(store.extraCriteria).toEqual([]);
+ expect(store.hasActiveFilters).toBe(false);
+ expect(store.bStart).toBe(0);
+ });
+
+ it("currentIds derives object ids from the loaded items", async () => {
+ mockedSearch.mockResolvedValue({
+ items: [
+ { "@id": "http://nohost/plone/folder/a" },
+ { "@id": "http://nohost/plone/folder/b/" },
+ ],
+ total: 2,
+ });
+ const store = makeStore();
+ await store.load();
+ expect(store.currentIds).toEqual(["a", "b"]);
+ });
+
+ it("fetchAllMatching loops pages until the total is collected", async () => {
+ const page = (n: number) =>
+ Array.from({ length: n }, (_, i) => ({ UID: `u${i}` }));
+ mockedSearch
+ .mockResolvedValueOnce({ items: page(1000), total: 1500 })
+ .mockResolvedValueOnce({ items: page(500), total: 1500 });
+ const store = makeStore();
+ const all = await store.fetchAllMatching(["UID"]);
+ expect(all).toHaveLength(1500);
+ expect(mockedSearch).toHaveBeenCalledTimes(2);
+ expect(mockedSearch.mock.calls[1][0].bStart).toBe(1000);
+ });
+
+ it("paste delegates to the target folder and reloads", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ await store.paste("cut", ["http://nohost/plone/a"]);
+ expect(mockedPaste).toHaveBeenCalledWith({
+ targetUrl: "http://nohost/plone/folder",
+ sources: ["http://nohost/plone/a"],
+ op: "cut",
+ });
+ expect(mockedSearch).toHaveBeenCalled();
+ });
+
+ it("moveIntoFolder @moves sources into the target folder and reloads", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ await store.moveIntoFolder("http://nohost/plone/folder/sub", [
+ "http://nohost/plone/folder/a",
+ ]);
+ expect(mockedPaste).toHaveBeenCalledWith({
+ targetUrl: "http://nohost/plone/folder/sub",
+ sources: ["http://nohost/plone/folder/a"],
+ op: "cut",
+ });
+ expect(mockedSearch).toHaveBeenCalled();
+ });
+
+ it("removeItems deletes urls then reloads, stepping back an empty page", async () => {
+ // page 2 is empty after the delete, so it should fall back to page 1
+ mockedSearch
+ .mockResolvedValueOnce({ items: [], total: 5 })
+ .mockResolvedValueOnce({ items: [{ UID: "x" }], total: 5 });
+ const store = makeStore();
+ store.bStart = 10;
+ await store.removeItems(["http://nohost/plone/a"]);
+ expect(mockedDelete).toHaveBeenCalledWith(
+ ["http://nohost/plone/a"],
+ undefined
+ );
+ expect(store.bStart).toBe(0);
+ });
+
+ it("moveTo reorders within the folder and reloads", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ await store.moveTo("doc-1", "top");
+ expect(mockedMove).toHaveBeenCalledWith({
+ containerUrl: "http://nohost/plone/folder",
+ id: "doc-1",
+ delta: "top",
+ subsetIds: undefined,
+ });
+ expect(mockedSearch).toHaveBeenCalled();
+ });
+
+ it("moveTo reorders items optimistically before the server responds", async () => {
+ mockedSearch.mockResolvedValue({
+ items: [
+ { UID: "a", "@id": "http://nohost/plone/folder/a" },
+ { UID: "b", "@id": "http://nohost/plone/folder/b" },
+ { UID: "c", "@id": "http://nohost/plone/folder/c" },
+ ],
+ total: 3,
+ });
+ const store = makeStore();
+ await store.load();
+ // Move "a" down two slots; the local array updates synchronously so the
+ // keyed rows can animate, before any server round-trip resolves.
+ const pending = store.moveTo("a", 2, store.currentIds);
+ expect(store.items.map((it) => it.UID)).toEqual(["b", "c", "a"]);
+ await pending;
+ });
+
+ it("moveTo reconciles silently, never toggling loading", async () => {
+ mockedSearch.mockResolvedValue({
+ items: [{ UID: "a", "@id": "http://nohost/plone/folder/a" }],
+ total: 1,
+ });
+ const store = makeStore();
+ await store.load();
+ const pending = store.moveTo("a", "bottom");
+ expect(store.loading).toBe(false);
+ await pending;
+ expect(store.loading).toBe(false);
+ });
+
+ it("moveTo restores server truth when the reorder PATCH fails", async () => {
+ mockedSearch.mockResolvedValue({
+ items: [
+ { UID: "a", "@id": "http://nohost/plone/folder/a" },
+ { UID: "b", "@id": "http://nohost/plone/folder/b" },
+ ],
+ total: 2,
+ });
+ const store = makeStore();
+ await store.load();
+ mockedMove.mockRejectedValueOnce(new Error("nope"));
+ await expect(store.moveTo("a", 1, store.currentIds)).rejects.toThrow("nope");
+ // The failed move reloaded the authoritative order from the server.
+ expect(store.items.map((it) => it.UID)).toEqual(["a", "b"]);
+ });
+
+ it("movePreview splices an item to a new slot without touching the server", async () => {
+ mockedSearch.mockResolvedValue({
+ items: [
+ { UID: "a", "@id": "http://nohost/plone/folder/a" },
+ { UID: "b", "@id": "http://nohost/plone/folder/b" },
+ { UID: "c", "@id": "http://nohost/plone/folder/c" },
+ ],
+ total: 3,
+ });
+ const store = makeStore();
+ await store.load();
+ store.movePreview("a", 2);
+ expect(store.items.map((it) => it.UID)).toEqual(["b", "c", "a"]);
+ expect(mockedMove).not.toHaveBeenCalled();
+ });
+
+ it("commitReorder PATCHes the net shift and reconciles silently", async () => {
+ mockedSearch.mockResolvedValue({
+ items: [{ UID: "a", "@id": "http://nohost/plone/folder/a" }],
+ total: 1,
+ });
+ const store = makeStore();
+ await store.load();
+ const pending = store.commitReorder("a", 2, ["a", "b", "c"]);
+ expect(store.loading).toBe(false); // silent reconcile, rows stay mounted
+ await pending;
+ expect(mockedMove).toHaveBeenCalledWith({
+ containerUrl: "http://nohost/plone/folder",
+ id: "a",
+ delta: 2,
+ subsetIds: ["a", "b", "c"],
+ });
+ });
+
+ it("commitReorder is a no-op when the net shift is zero", async () => {
+ const store = makeStore();
+ await store.commitReorder("a", 0, ["a", "b"]);
+ expect(mockedMove).not.toHaveBeenCalled();
+ });
+
+ it("movePreviewBlock lifts a contiguous run and re-inserts it as one", async () => {
+ mockedSearch.mockResolvedValue({
+ items: ["a", "b", "c", "d", "e"].map((id) => ({
+ UID: id,
+ "@id": `http://nohost/plone/folder/${id}`,
+ })),
+ total: 5,
+ });
+ const store = makeStore();
+ await store.load();
+ store.movePreviewBlock(["b", "c"], 3);
+ expect(store.items.map((it) => it.UID)).toEqual(["a", "d", "e", "b", "c"]);
+ expect(mockedMove).not.toHaveBeenCalled();
+ });
+
+ it("commitReorderBlock moves a block down as a sequence of single moves", async () => {
+ mockedSearch.mockResolvedValue({
+ items: ["a", "b", "c", "d", "e"].map((id) => ({
+ UID: id,
+ "@id": `http://nohost/plone/folder/${id}`,
+ })),
+ total: 5,
+ });
+ const store = makeStore();
+ await store.load();
+ // Move {b, c} to start at index 3 → trailing item first so placed rows
+ // aren't disturbed, each subset matching the live server order.
+ await store.commitReorderBlock(["b", "c"], 3, ["a", "b", "c", "d", "e"]);
+ expect(mockedMove.mock.calls.map((c) => c[0])).toEqual([
+ {
+ containerUrl: "http://nohost/plone/folder",
+ id: "c",
+ delta: 2,
+ subsetIds: ["a", "b", "c", "d", "e"],
+ },
+ {
+ containerUrl: "http://nohost/plone/folder",
+ id: "b",
+ delta: 2,
+ subsetIds: ["a", "b", "d", "e", "c"],
+ },
+ ]);
+ });
+
+ it("commitReorderBlock moves a block up top-down", async () => {
+ mockedSearch.mockResolvedValue({
+ items: ["a", "b", "c", "d", "e"].map((id) => ({
+ UID: id,
+ "@id": `http://nohost/plone/folder/${id}`,
+ })),
+ total: 5,
+ });
+ const store = makeStore();
+ await store.load();
+ await store.commitReorderBlock(["c", "d"], 1, ["a", "b", "c", "d", "e"]);
+ expect(mockedMove.mock.calls.map((c) => c[0])).toEqual([
+ {
+ containerUrl: "http://nohost/plone/folder",
+ id: "c",
+ delta: -1,
+ subsetIds: ["a", "b", "c", "d", "e"],
+ },
+ {
+ containerUrl: "http://nohost/plone/folder",
+ id: "d",
+ delta: -1,
+ subsetIds: ["a", "c", "b", "d", "e"],
+ },
+ ]);
+ });
+
+ it("commitReorderBlock is a no-op when the block does not move", async () => {
+ const store = makeStore();
+ await store.commitReorderBlock(["b", "c"], 1, ["a", "b", "c", "d"]);
+ expect(mockedMove).not.toHaveBeenCalled();
+ });
+
+ it("makeDefaultPage sets the container default page", async () => {
+ const store = makeStore();
+ await store.makeDefaultPage("doc-1");
+ expect(mockedDefaultPage).toHaveBeenCalledWith({
+ containerUrl: "http://nohost/plone/folder",
+ id: "doc-1",
+ });
+ });
+
+ it("rearrange PATCHes the container and reloads in manual-order mode", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ store.sortOn = "Title";
+ await store.rearrange("sortable_title", "ascending");
+ expect(mockedRearrange).toHaveBeenCalledWith({
+ containerUrl: "http://nohost/plone/folder",
+ sortOn: "sortable_title",
+ sortOrder: "ascending",
+ });
+ expect(store.sortOn).toBe("getObjPositionInParent");
+ expect(store.sortOrder).toBe("ascending");
+ expect(store.bStart).toBe(0);
+ expect(mockedSearch).toHaveBeenCalled();
+ });
+
+ it("rearrange supports descending order", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ await store.rearrange("modified", "descending");
+ expect(mockedRearrange).toHaveBeenCalledWith({
+ containerUrl: "http://nohost/plone/folder",
+ sortOn: "modified",
+ sortOrder: "descending",
+ });
+ });
+
+ it("applyWorkflow transitions each item and reports a summary", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ const result = await store.applyWorkflow(
+ [
+ { url: "http://nohost/plone/a", title: "A", isFolderish: false },
+ { url: "http://nohost/plone/b", title: "B", isFolderish: true },
+ ],
+ { transition: "publish", comment: "go", includeChildren: true }
+ );
+ expect(mockedTransition).toHaveBeenCalledTimes(2);
+ expect(mockedTransition).toHaveBeenNthCalledWith(1, {
+ itemUrl: "http://nohost/plone/a",
+ transition: "publish",
+ comment: "go",
+ includeChildren: true,
+ });
+ expect(result).toEqual({ ok: 2, failed: [] });
+ expect(mockedSearch).toHaveBeenCalled();
+ });
+
+ it("applyWorkflow records per-item failures without aborting", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ mockedTransition
+ .mockRejectedValueOnce(new Error("not allowed"))
+ .mockResolvedValueOnce(undefined);
+ const store = makeStore();
+ const result = await store.applyWorkflow(
+ [
+ { url: "http://nohost/plone/a", title: "A", isFolderish: false },
+ { url: "http://nohost/plone/b", title: "B", isFolderish: false },
+ ],
+ { transition: "publish" }
+ );
+ expect(result.ok).toBe(1);
+ expect(result.failed).toEqual([{ title: "A", error: "not allowed" }]);
+ });
+
+ it("applyTags computes per-item subjects (remove then add)", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ await store.applyTags(
+ [
+ {
+ url: "http://nohost/plone/a",
+ title: "A",
+ isFolderish: false,
+ subjects: ["keep", "drop"],
+ },
+ ],
+ { add: ["new"], remove: ["drop"] }
+ );
+ expect(mockedPatch).toHaveBeenCalledWith("http://nohost/plone/a", {
+ subjects: ["keep", "new"],
+ });
+ });
+
+ it("applyProperties patches each item and recurses into folders", async () => {
+ // first call: the recursive descendant sweep; later calls: reload()
+ mockedSearch
+ .mockResolvedValueOnce({
+ items: [{ "@id": "http://nohost/plone/folder/sub/child" }],
+ total: 1,
+ })
+ .mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ const result = await store.applyProperties(
+ [
+ { url: "http://nohost/plone/folder/sub", title: "Sub", isFolderish: true },
+ ],
+ { rights: "CC" },
+ true
+ );
+ expect(mockedPatch).toHaveBeenCalledWith("http://nohost/plone/folder/sub", {
+ rights: "CC",
+ });
+ expect(mockedPatch).toHaveBeenCalledWith("http://nohost/plone/folder/sub/child", {
+ rights: "CC",
+ });
+ expect(result.ok).toBe(1);
+ });
+
+ it("applyProperties without recursion patches only the selected item", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ await store.applyProperties(
+ [{ url: "http://nohost/plone/folder/sub", title: "Sub", isFolderish: true }],
+ { rights: "CC" },
+ false
+ );
+ expect(mockedPatch).toHaveBeenCalledTimes(1);
+ });
+
+ it("renameItems patches id and title per item", async () => {
+ mockedSearch.mockResolvedValue({ items: [], total: 0 });
+ const store = makeStore();
+ const result = await store.renameItems([
+ { url: "http://nohost/plone/a", id: "a-new", title: "A New" },
+ ]);
+ expect(mockedPatch).toHaveBeenCalledWith("http://nohost/plone/a", {
+ id: "a-new",
+ title: "A New",
+ });
+ expect(result).toEqual({ ok: 1, failed: [] });
+ });
+});
+
+describe("ConfigStore", () => {
+ it("derives contextPath from the url and strips trailing slashes", () => {
+ const config = new ConfigStore({ contextUrl: "http://nohost/plone/folder/" });
+ expect(config.contextUrl).toBe("http://nohost/plone/folder");
+ expect(config.contextPath).toBe("/plone/folder");
+ });
+
+ it("falls back to default active/available columns", () => {
+ const config = new ConfigStore({ contextUrl: "http://nohost/plone" });
+ expect(config.activeColumns).toContain("Title");
+ expect(config.availableColumns).toContain("Subject");
+ });
+
+ it("resolves a column definition by key", () => {
+ const config = new ConfigStore({ contextUrl: "http://nohost/plone" });
+ expect(config.column("ModificationDate").type).toBe("date");
+ expect(config.column("unknown")).toEqual({
+ key: "unknown",
+ label: "unknown",
+ type: "text",
+ });
+ });
+});
diff --git a/src/pat/filemanager/src/stores/FolderDropStore.svelte.ts b/src/pat/filemanager/src/stores/FolderDropStore.svelte.ts
new file mode 100644
index 000000000..e4ea4b6d1
--- /dev/null
+++ b/src/pat/filemanager/src/stores/FolderDropStore.svelte.ts
@@ -0,0 +1,46 @@
+import type { DropManifest } from "../utils/dropentries";
+
+// The approval gate for a folder drop. A dropped folder can mean a large,
+// hard-to-undo bulk import, so `preview()` opens a dialog showing what *would*
+// be created/uploaded and resolves true/false on the user's decision — an
+// awaitable approval, mirroring ConfirmStore but carrying the full manifest.
+// FolderDropPreview.svelte renders the open state.
+
+export class FolderDropStore {
+ manifest = $state(null);
+ /** Display name of the container the folder would be created in. */
+ targetName = $state("");
+
+ private resolver: ((ok: boolean) => void) | null = null;
+
+ get isOpen(): boolean {
+ return this.manifest !== null;
+ }
+
+ /** Open the preview; resolves true on approve, false on cancel/dismiss. */
+ preview(manifest: DropManifest, targetName: string): Promise {
+ // A new preview supersedes any still-pending one (cancel the old).
+ this.resolver?.(false);
+ this.manifest = manifest;
+ this.targetName = targetName;
+ return new Promise((resolve) => {
+ this.resolver = resolve;
+ });
+ }
+
+ private settle(ok: boolean): void {
+ const resolve = this.resolver;
+ this.resolver = null;
+ this.manifest = null;
+ this.targetName = "";
+ resolve?.(ok);
+ }
+
+ approve(): void {
+ this.settle(true);
+ }
+
+ cancel(): void {
+ this.settle(false);
+ }
+}
diff --git a/src/pat/filemanager/src/stores/FolderDropStore.test.ts b/src/pat/filemanager/src/stores/FolderDropStore.test.ts
new file mode 100644
index 000000000..20657af20
--- /dev/null
+++ b/src/pat/filemanager/src/stores/FolderDropStore.test.ts
@@ -0,0 +1,41 @@
+import { FolderDropStore } from "./FolderDropStore.svelte";
+import type { DropManifest } from "../utils/dropentries";
+
+const manifest = {
+ files: [],
+ dirs: ["F"],
+ fileCount: 0,
+ folderCount: 1,
+ totalSize: 0,
+ hasDirectories: true,
+} as DropManifest;
+
+describe("FolderDropStore", () => {
+ it("opens on preview and resolves true on approve", async () => {
+ const store = new FolderDropStore();
+ const decision = store.preview(manifest, "Target");
+ expect(store.isOpen).toBe(true);
+ expect(store.targetName).toBe("Target");
+ store.approve();
+ expect(await decision).toBe(true);
+ expect(store.isOpen).toBe(false);
+ });
+
+ it("resolves false on cancel", async () => {
+ const store = new FolderDropStore();
+ const decision = store.preview(manifest, "Target");
+ store.cancel();
+ expect(await decision).toBe(false);
+ expect(store.isOpen).toBe(false);
+ });
+
+ it("supersedes a pending preview, resolving the first false", async () => {
+ const store = new FolderDropStore();
+ const first = store.preview(manifest, "A");
+ const second = store.preview(manifest, "B");
+ expect(await first).toBe(false);
+ expect(store.targetName).toBe("B");
+ store.approve();
+ expect(await second).toBe(true);
+ });
+});
diff --git a/src/pat/filemanager/src/stores/ListInteractions.svelte.ts b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts
new file mode 100644
index 000000000..98b972bb4
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ListInteractions.svelte.ts
@@ -0,0 +1,475 @@
+import { objId } from "../api/operations.js";
+import { _t } from "../utils/i18n";
+import {
+ captureDropEntries,
+ entriesHaveDirectory,
+ readDropManifest,
+} from "../utils/dropentries";
+import type { ClipboardStore } from "./ClipboardStore.svelte";
+import type { ConfirmStore } from "./ConfirmStore.svelte";
+import type { ContentsStore, ContentItem } from "./ContentsStore.svelte";
+import type { FolderDropStore } from "./FolderDropStore.svelte";
+import type { ProgressStore } from "./ProgressStore.svelte";
+import type { SelectionStore } from "./SelectionStore.svelte";
+import type { UploadStore } from "./UploadStore.svelte";
+
+// Shared list-interaction logic (selection clicks + drag) for any view that
+// renders the listing. Extracted from ContentTable so the grid reuses exactly
+// the same selection and drag-into-folder/reorder behaviour.
+//
+// The drag gesture itself is owned by sortablejs (see utils/sortable.ts); this
+// controller only makes the decisions. sortablejs calls `dragStart`, `dragMove`
+// and `dragEnd`, which translate a drag into one of three outcomes: a reorder
+// within the listing, a move into a hovered folder, or a move into the parent
+// container. External file drags (uploads) never involve sortablejs and are
+// handled by the separate `on*Drag*`/`on*Drop` file handlers below.
+
+export class ListInteractions {
+ contents: ContentsStore;
+ selection: SelectionStore;
+ clipboard: ClipboardStore;
+ upload?: UploadStore;
+ confirm?: ConfirmStore;
+ progress?: ProgressStore;
+ folderDrop?: FolderDropStore;
+
+ // `dragActive` is true while a sortablejs item drag is in progress, so the
+ // file handlers below stand down and let sortablejs own the gesture.
+ // `dropIndex` is the folderish item currently highlighted as a move-into
+ // target; `fileDropIndex` is the folderish item highlighted while dragging
+ // external files over it (upload into that subfolder); `anchorIndex` is the
+ // pivot for shift-click range selection.
+ dragActive = $state(false);
+ dropIndex = $state(-1);
+ fileDropIndex = $state(-1);
+ anchorIndex = $state(-1);
+ // Highlight for the grid's "up to parent" placeholder while an item drag or
+ // an external file drag hovers it (drop = move/upload into the parent).
+ parentDrop = $state(false);
+
+ // Drag bookkeeping captured at drag start: the dragged row's model index,
+ // its url, and the server order snapshotted then so a reorder drop commits a
+ // single relative move against the order the server still has.
+ private dragStartIndex = -1;
+ private draggedId: string | null = null;
+ private dragSubset: string[] = [];
+
+ constructor(
+ contents: ContentsStore,
+ selection: SelectionStore,
+ clipboard: ClipboardStore,
+ upload?: UploadStore,
+ confirm?: ConfirmStore,
+ progress?: ProgressStore,
+ folderDrop?: FolderDropStore
+ ) {
+ this.contents = contents;
+ this.selection = selection;
+ this.clipboard = clipboard;
+ this.upload = upload;
+ this.confirm = confirm;
+ this.progress = progress;
+ this.folderDrop = folderDrop;
+ }
+
+ /**
+ * Ask the user to confirm an action. Uses the app's -based
+ * ConfirmStore when available, falling back to the native window.confirm.
+ */
+ private async confirmAction(
+ message: string,
+ confirmLabel: string
+ ): Promise {
+ if (this.confirm) return this.confirm.ask(message, { confirmLabel });
+ return window.confirm(message);
+ }
+
+ /** Reorder only makes sense while the listing is in manual-order mode. */
+ get canReorder(): boolean {
+ return this.contents.sortOn === "getObjPositionInParent";
+ }
+
+ // Clicks on these controls (links, buttons, the checkbox, its label) keep
+ // their own behaviour and must not trigger row/card selection.
+ private isInteractive(target: EventTarget | null): boolean {
+ return Boolean(
+ (target as HTMLElement | null)?.closest("a, button, input, label")
+ );
+ }
+
+ // Plain → replace selection; ctrl/meta → toggle; shift → range from anchor.
+ private applySelection(
+ item: ContentItem,
+ index: number,
+ { range, toggle }: { range: boolean; toggle: boolean }
+ ): void {
+ if (range && this.anchorIndex >= 0) {
+ this.selection.selectRange(this.contents.items, this.anchorIndex, index);
+ } else if (toggle) {
+ this.selection.toggle(item);
+ this.anchorIndex = index;
+ } else {
+ this.selection.selectOnly(item);
+ this.anchorIndex = index;
+ }
+ }
+
+ onItemClick(event: MouseEvent, item: ContentItem, index: number): void {
+ if (this.isInteractive(event.target)) return;
+ this.applySelection(item, index, {
+ range: event.shiftKey,
+ toggle: event.ctrlKey || event.metaKey,
+ });
+ }
+
+ /**
+ * Grid cards act like big checkboxes: a plain click toggles the card's
+ * selection, so clicking it again deselects it (mirroring Space and the
+ * card's own checkbox); Shift extends the range from the anchor. The table
+ * keeps onItemClick's click-to-replace model, where clicking a row selects
+ * only it.
+ */
+ onCardClick(event: MouseEvent, item: ContentItem, index: number): void {
+ if (this.isInteractive(event.target)) return;
+ this.applySelection(item, index, {
+ range: event.shiftKey,
+ toggle: !event.shiftKey,
+ });
+ }
+
+ /**
+ * Keyboard model for a focusable grid card (the single tab stop per item;
+ * its checkbox and title link are out of the tab order): Space selects
+ * (modifier-aware), Enter opens. Skipped if focus is on a nested control.
+ */
+ onItemKeydown(event: KeyboardEvent, item: ContentItem, index: number): void {
+ if (this.isInteractive(event.target)) return;
+ if (event.key === " ") {
+ event.preventDefault();
+ // Space toggles the focused card (like its checkbox), so a second
+ // press deselects it; Shift+Space extends the range from the anchor.
+ this.applySelection(item, index, {
+ range: event.shiftKey,
+ toggle: true,
+ });
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+ this.activate(item);
+ }
+ }
+
+ /** Open an item from the keyboard: folders drill in-app, others navigate. */
+ activate(item: ContentItem): void {
+ if (item.is_folderish) {
+ this.selection.clear();
+ this.contents.navigateTo(item["@id"]);
+ return;
+ }
+ window.location.assign(item["@id"]);
+ }
+
+ /** Stop shift-click from highlighting cell text while range-selecting. */
+ onItemMouseDown(event: MouseEvent): void {
+ if (event.shiftKey && !this.isInteractive(event.target)) {
+ event.preventDefault();
+ }
+ }
+
+ isCut(item: ContentItem): boolean {
+ return (
+ this.clipboard.op === "cut" && this.clipboard.sources.includes(item["@id"])
+ );
+ }
+
+ // ── sortablejs drag hooks ────────────────────────────────────────────────
+ // The action in utils/sortable.ts calls these; they hold no DOM references.
+
+ /** A drag began on the listing item at model index `index`. */
+ dragStart(index: number): void {
+ this.dragActive = true;
+ this.dragStartIndex = index;
+ this.draggedId = this.contents.items[index]?.["@id"] ?? null;
+ // Snapshot the server order now so a reorder drop commits a relative move
+ // against the order the server still has.
+ this.dragSubset = this.canReorder ? [...this.contents.currentIds] : [];
+ this.dropIndex = -1;
+ this.parentDrop = false;
+ this.fileDropIndex = -1;
+ }
+
+ /**
+ * A hover during the drag, over the listing item at model index
+ * `relatedIndex` (-1 when not over an item). Returns whether sortablejs may
+ * reorder-swap for this hover:
+ *
+ * - over the parent placeholder (tracked by the native handlers) → false,
+ * keep the list still so only the placeholder highlights;
+ * - over any folder but the dragged item itself → false, and highlight the
+ * whole folder as a move-into target. Folders are *solid* drop targets:
+ * never swapping the dragged item with a folder keeps the folder still
+ * under the pointer, so aiming at it to drop inside is reliable (a
+ * swapping folder would slide away as you approached it — especially in
+ * the vertical table, where every crossed row swaps). Reordering past a
+ * folder still works by hovering the next non-folder item beyond it;
+ * - otherwise reorder, but only in manual-order mode.
+ */
+ dragMove(relatedIndex: number): boolean {
+ if (this.parentDrop) {
+ this.dropIndex = -1;
+ return false;
+ }
+ const target = relatedIndex >= 0 ? this.contents.items[relatedIndex] : undefined;
+ if (target?.is_folderish && target["@id"] !== this.draggedId) {
+ this.dropIndex = relatedIndex;
+ return false;
+ }
+ this.dropIndex = -1;
+ return this.canReorder;
+ }
+
+ /**
+ * The drag ended. `delta` is the dragged row's net index shift within the
+ * listing (sortablejs's newIndex − oldIndex). The last hover decided the
+ * gesture: a parent-placeholder or folder move takes precedence over a
+ * reorder; otherwise, in manual-order mode, commit the reorder.
+ */
+ async dragEnd(delta: number): Promise {
+ const active = this.dragActive;
+ const into = this.dropIndex;
+ const parent = this.parentDrop;
+ const from = this.dragStartIndex;
+ const draggedId = this.draggedId;
+ const subset = this.dragSubset;
+ this.resetDrag();
+ if (!active || from < 0 || !draggedId) return;
+ const dragged = this.contents.items[from];
+ if (!dragged) return;
+ if (parent) {
+ await this.moveToParent(dragged);
+ return;
+ }
+ if (into >= 0 && into !== from) {
+ const target = this.contents.items[into];
+ if (target?.is_folderish) await this.moveToFolder(target, dragged);
+ return;
+ }
+ if (!this.canReorder || delta === 0) return;
+ await this.contents.moveTo(objId(draggedId), delta, subset);
+ }
+
+ /** Clear all drag bookkeeping (shared by a committed drop and a cancel). */
+ private resetDrag(): void {
+ this.dragActive = false;
+ this.dropIndex = -1;
+ this.fileDropIndex = -1;
+ this.parentDrop = false;
+ this.dragStartIndex = -1;
+ this.draggedId = null;
+ this.dragSubset = [];
+ }
+
+ // The urls to move when dragging an item: the whole selection if the dragged
+ // item is part of a multi-selection, otherwise just that item.
+ private dragSources(dragged: ContentItem): string[] {
+ if (this.selection.isSelected(dragged) && this.selection.count > 1) {
+ return this.selection.urls;
+ }
+ return [dragged["@id"]];
+ }
+
+ /** Move the dragged sources (or whole selection) into `target` folder. */
+ private async moveToFolder(
+ target: ContentItem,
+ dragged: ContentItem
+ ): Promise {
+ const sources = this.dragSources(dragged);
+ const folder = (target.Title as string) || objId(target["@id"]);
+ // Moving into a folder takes the items out of the current listing, so
+ // confirm before committing it.
+ const ok = await this.confirmAction(
+ _t('Move ${count} item(s) into "${folder}"?', {
+ count: sources.length,
+ folder,
+ }),
+ _t("Move")
+ );
+ if (!ok) return;
+ // @move is a single server request, so the bar is indeterminate (no
+ // per-item progress). Surface it as a busy overlay on the target row/card.
+ const move = () => this.contents.moveIntoFolder(target["@id"], sources);
+ if (this.progress) {
+ await this.progress.track(
+ _t('Moving ${count} item(s) into "${folder}"…', {
+ count: sources.length,
+ folder,
+ }),
+ move,
+ { surface: "folder", targetUrl: target["@id"] }
+ );
+ } else {
+ await move();
+ }
+ this.selection.clear();
+ }
+
+ /** Move the dragged sources (or whole selection) into the parent container. */
+ private async moveToParent(dragged: ContentItem): Promise {
+ const parentUrl = this.contents.parentUrl;
+ const sources = this.dragSources(dragged);
+ if (!parentUrl || sources.length === 0) return;
+ const ok = await this.confirmAction(
+ _t("Move ${count} item(s) to the parent folder?", { count: sources.length }),
+ _t("Move")
+ );
+ if (!ok) return;
+ const move = () => this.contents.moveIntoFolder(parentUrl, sources);
+ if (this.progress) {
+ await this.progress.track(
+ _t("Moving ${count} item(s) to the parent folder…", {
+ count: sources.length,
+ }),
+ move,
+ { surface: "folder", targetUrl: parentUrl }
+ );
+ } else {
+ await move();
+ }
+ this.selection.clear();
+ }
+
+ // ── external file drags (uploads) ────────────────────────────────────────
+ // These travel through native DOM events on a row/card. While a sortablejs
+ // item drag is active they stand down (`dragActive`); otherwise they route
+ // an OS file drop into the hovered subfolder, or let it bubble to the upload
+ // zone (current folder) for non-folder rows.
+
+ private hasFiles(event: DragEvent): boolean {
+ const types = event.dataTransfer?.types;
+ return Boolean(types && Array.from(types).includes("Files"));
+ }
+
+ /**
+ * The single entry point for an external (OS) file/folder drop, used by the
+ * upload zone, subfolder rows and the parent placeholder. A plain file drop
+ * takes today's path (immediate upload, no prompt). A drop that contains a
+ * folder is read into a manifest, previewed for approval, and on approval
+ * recreated + uploaded as a tree. `targetUrl` defaults to the current folder.
+ *
+ * `captureDropEntries` and the `files` read MUST stay in the synchronous
+ * prefix (before the first await): the DataTransfer is only live while the
+ * drop event is being dispatched, and this method is entered straight from
+ * the native handler.
+ */
+ async handleExternalDrop(
+ dataTransfer: DataTransfer | null,
+ targetUrl?: string
+ ): Promise {
+ const target = targetUrl ?? this.contents.contextUrl;
+ const entries = captureDropEntries(dataTransfer);
+ const files = Array.from(dataTransfer?.files ?? []);
+ if (!this.upload) return;
+ if (!entriesHaveDirectory(entries)) {
+ // Flat file drop (or a browser without the entries API): unchanged.
+ if (files.length) await this.upload.uploadFiles(files, targetUrl);
+ return;
+ }
+ const manifest = await readDropManifest(entries);
+ if (manifest.fileCount === 0 && manifest.folderCount === 0) return;
+ const name = objId(target) || target;
+ if (this.folderDrop && !(await this.folderDrop.preview(manifest, name))) {
+ return;
+ }
+ await this.upload.uploadTree(target, manifest, this.contents.config.folderType);
+ }
+
+ onRowDragEnter(event: DragEvent, index: number): void {
+ if (this.dragActive || !this.hasFiles(event)) return;
+ const item = this.contents.items[index];
+ this.fileDropIndex = item?.is_folderish ? index : -1;
+ }
+
+ onRowDragOver(event: DragEvent, index: number): void {
+ if (this.dragActive || !this.hasFiles(event)) return;
+ const item = this.contents.items[index];
+ if (!item?.is_folderish) {
+ // A non-folder row lets the drop bubble to the upload zone (current
+ // folder); drop any lingering subfolder highlight.
+ if (this.fileDropIndex === index) this.fileDropIndex = -1;
+ return;
+ }
+ // A subfolder row claims the drop: allow it and mark the target.
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
+ this.fileDropIndex = index;
+ }
+
+ onRowDrop(event: DragEvent, index: number): void | Promise {
+ if (this.dragActive) return;
+ return this.onFileDrop(event, index);
+ }
+
+ // The grid's "up to parent" placeholder card. While a sortablejs item drag
+ // is active, hovering it marks `parentDrop` so the drop commits a move into
+ // the parent (sortablejs's onEnd reads the flag); an external file drag
+ // uploads into the parent instead.
+
+ onParentDragEnter(event: DragEvent): void {
+ if (this.dragActive) {
+ this.parentDrop = true;
+ this.dropIndex = -1;
+ return;
+ }
+ if (this.hasFiles(event)) this.parentDrop = true;
+ }
+
+ onParentDragOver(event: DragEvent): void {
+ if (this.dragActive) {
+ event.preventDefault();
+ // Re-affirm the highlight every dragover: dragleave fires when the
+ // pointer crosses onto the placeholder's own children, clearing it,
+ // so the steady stream of dragover events is what keeps it lit.
+ this.parentDrop = true;
+ this.dropIndex = -1;
+ return;
+ }
+ if (!this.hasFiles(event)) return;
+ event.preventDefault();
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
+ this.parentDrop = true;
+ }
+
+ onParentDragLeave(): void {
+ this.parentDrop = false;
+ }
+
+ async onParentDrop(event: DragEvent): Promise {
+ // Internal sortablejs drag → the move into the parent is committed by
+ // dragEnd via the parentDrop flag; just accept the drop here so the
+ // browser doesn't treat it as a navigation/file drop.
+ if (this.dragActive) {
+ event.preventDefault();
+ return;
+ }
+ // External file/folder drag → upload into the parent folder.
+ const parentUrl = this.contents.parentUrl;
+ this.parentDrop = false;
+ if (!this.hasFiles(event) || !parentUrl) return;
+ event.preventDefault();
+ await this.handleExternalDrop(event.dataTransfer, parentUrl);
+ }
+
+ /**
+ * Upload files/folders dropped directly onto a subfolder row/card into that
+ * folder. Calling preventDefault (without stopPropagation) marks the event
+ * handled; the upload zone sees the same bubbling drop and uploads to the
+ * current folder only when no subfolder claimed it.
+ */
+ async onFileDrop(event: DragEvent, index: number): Promise {
+ if (!this.hasFiles(event)) return;
+ const item = this.contents.items[index];
+ if (!item?.is_folderish) return;
+ event.preventDefault();
+ this.fileDropIndex = -1;
+ await this.handleExternalDrop(event.dataTransfer, item["@id"]);
+ }
+}
diff --git a/src/pat/filemanager/src/stores/ListInteractions.test.ts b/src/pat/filemanager/src/stores/ListInteractions.test.ts
new file mode 100644
index 000000000..ec638c42c
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ListInteractions.test.ts
@@ -0,0 +1,528 @@
+import { ListInteractions } from "./ListInteractions.svelte";
+
+function item(id: string, extra: Record = {}) {
+ return {
+ "@id": `http://nohost/plone/folder/${id}`,
+ "UID": id,
+ "Title": id,
+ ...extra,
+ };
+}
+
+function makeContents(items: ReturnType[]) {
+ return {
+ items,
+ sortOn: "getObjPositionInParent",
+ parentUrl: "http://nohost/plone" as string | null,
+ get currentIds() {
+ return items.map((it) => it["@id"].split("/").pop());
+ },
+ moveIntoFolder: jest.fn().mockResolvedValue(undefined),
+ moveTo: jest.fn().mockResolvedValue(undefined),
+ load: jest.fn().mockResolvedValue(undefined),
+ navigateTo: jest.fn().mockResolvedValue(undefined),
+ };
+}
+
+/** A selection mock that reports the given object-ids (item.UID) as selected. */
+function selectionFor(ids: string[]) {
+ return {
+ selectRange: jest.fn(),
+ toggle: jest.fn(),
+ selectOnly: jest.fn(),
+ clear: jest.fn(),
+ isSelected: jest.fn((it: { UID: string }) => ids.includes(it.UID)),
+ count: ids.length,
+ urls: ids.map((id) => `http://nohost/plone/folder/${id}`),
+ };
+}
+
+function makeSelection() {
+ return {
+ selectRange: jest.fn(),
+ toggle: jest.fn(),
+ selectOnly: jest.fn(),
+ clear: jest.fn(),
+ isSelected: jest.fn().mockReturnValue(false),
+ count: 0,
+ urls: [] as string[],
+ };
+}
+
+function makeClipboard(op: "cut" | "copy" | null = null, sources: string[] = []) {
+ return { op, sources };
+}
+
+function makeUpload() {
+ return { uploadFiles: jest.fn().mockResolvedValue({ ok: 1, failed: [] }) };
+}
+
+/** A confirm-store mock whose ask() resolves to `accept` (default: confirmed). */
+function makeConfirm(accept = true) {
+ return { ask: jest.fn().mockResolvedValue(accept) };
+}
+
+function make(
+ items: ReturnType[],
+ selection = makeSelection(),
+ clipboard = makeClipboard(),
+ upload = makeUpload(),
+ confirm: ReturnType | undefined = undefined
+) {
+ const contents = makeContents(items);
+ const interactions = new ListInteractions(
+ contents as never,
+ selection as never,
+ clipboard as never,
+ upload as never,
+ confirm as never
+ );
+ return { interactions, contents, selection, clipboard, upload, confirm };
+}
+
+// A minimal DragEvent carrying a `Files` payload (or not), with the bits the
+// handlers read: dataTransfer.types/files/dropEffect and preventDefault.
+function dragEvent(files: Array<{ name: string }> = []) {
+ return {
+ preventDefault: jest.fn(),
+ dataTransfer: {
+ types: ["Files"],
+ files,
+ dropEffect: "none",
+ },
+ } as unknown as DragEvent;
+}
+
+function nonFileDragEvent() {
+ return {
+ preventDefault: jest.fn(),
+ dataTransfer: { types: ["text/plain"], files: [], dropEffect: "none" },
+ } as unknown as DragEvent;
+}
+
+const clickEvent = (opts: Record = {}) =>
+ ({ target: null, ...opts } as unknown as MouseEvent);
+const keyEvent = (opts: Record = {}) =>
+ ({ target: null, preventDefault: jest.fn(), ...opts } as unknown as KeyboardEvent);
+
+describe("ListInteractions — selection clicks", () => {
+ it("plain click selects only that item and sets the anchor", () => {
+ const { interactions, selection, contents } = make([item("a"), item("b")]);
+ interactions.onItemClick(clickEvent(), contents.items[0], 0);
+ expect(selection.selectOnly).toHaveBeenCalledWith(contents.items[0]);
+ expect(interactions.anchorIndex).toBe(0);
+ });
+
+ it("ctrl/meta click toggles the item and moves the anchor", () => {
+ const { interactions, selection, contents } = make([item("a"), item("b")]);
+ interactions.onItemClick(clickEvent({ ctrlKey: true }), contents.items[1], 1);
+ expect(selection.toggle).toHaveBeenCalledWith(contents.items[1]);
+ expect(interactions.anchorIndex).toBe(1);
+ });
+
+ it("shift click selects the range from the anchor", () => {
+ const { interactions, selection, contents } = make([
+ item("a"),
+ item("b"),
+ item("c"),
+ ]);
+ interactions.onItemClick(clickEvent(), contents.items[0], 0);
+ interactions.onItemClick(clickEvent({ shiftKey: true }), contents.items[2], 2);
+ expect(selection.selectRange).toHaveBeenCalledWith(contents.items, 0, 2);
+ });
+
+ it("ignores clicks on interactive descendants (links, checkbox, buttons)", () => {
+ const { interactions, selection, contents } = make([item("a")]);
+ const event = clickEvent({ target: { closest: () => ({}) } });
+ interactions.onItemClick(event, contents.items[0], 0);
+ expect(selection.selectOnly).not.toHaveBeenCalled();
+ });
+
+ it("grid card click toggles the card so a second click deselects it", () => {
+ const { interactions, selection, contents } = make([item("a")]);
+ interactions.onCardClick(clickEvent(), contents.items[0], 0);
+ interactions.onCardClick(clickEvent(), contents.items[0], 0);
+ expect(selection.toggle).toHaveBeenCalledTimes(2);
+ expect(selection.toggle).toHaveBeenCalledWith(contents.items[0]);
+ expect(selection.selectOnly).not.toHaveBeenCalled();
+ expect(interactions.anchorIndex).toBe(0);
+ });
+
+ it("shift card click selects the range from the anchor", () => {
+ const { interactions, selection, contents } = make([
+ item("a"),
+ item("b"),
+ item("c"),
+ ]);
+ interactions.onCardClick(clickEvent(), contents.items[0], 0); // anchor 0
+ interactions.onCardClick(clickEvent({ shiftKey: true }), contents.items[2], 2);
+ expect(selection.selectRange).toHaveBeenCalledWith(contents.items, 0, 2);
+ });
+
+ it("Space toggles the focused card so a second press deselects it", () => {
+ const { interactions, selection, contents } = make([item("a")]);
+ interactions.onItemKeydown(keyEvent({ key: " " }), contents.items[0], 0);
+ interactions.onItemKeydown(keyEvent({ key: " " }), contents.items[0], 0);
+ expect(selection.toggle).toHaveBeenCalledTimes(2);
+ expect(selection.toggle).toHaveBeenCalledWith(contents.items[0]);
+ expect(selection.selectOnly).not.toHaveBeenCalled();
+ });
+
+ it("Shift+Space extends the range from the anchor", () => {
+ const { interactions, selection, contents } = make([
+ item("a"),
+ item("b"),
+ item("c"),
+ ]);
+ interactions.onItemClick(clickEvent(), contents.items[0], 0); // anchor 0
+ interactions.onItemKeydown(
+ keyEvent({ key: " ", shiftKey: true }),
+ contents.items[2],
+ 2
+ );
+ expect(selection.selectRange).toHaveBeenCalledWith(contents.items, 0, 2);
+ });
+
+ it("Enter opens a folder card in-app and clears the selection", () => {
+ const folder = item("f", { is_folderish: true });
+ const { interactions, selection, contents } = make([folder]);
+ const enter = keyEvent({ key: "Enter" });
+ interactions.onItemKeydown(enter, contents.items[0], 0);
+ expect(enter.preventDefault).toHaveBeenCalled();
+ expect(selection.clear).toHaveBeenCalled();
+ expect(contents.navigateTo).toHaveBeenCalledWith(folder["@id"]);
+ });
+
+ it("ignores keys other than Space and Enter", () => {
+ const { interactions, selection, contents } = make([item("a")]);
+ const other = keyEvent({ key: "x" });
+ interactions.onItemKeydown(other, contents.items[0], 0);
+ expect(other.preventDefault).not.toHaveBeenCalled();
+ expect(selection.selectOnly).not.toHaveBeenCalled();
+ });
+});
+
+describe("ListInteractions — drag state", () => {
+ it("marks a drag active on start and clears all bookkeeping on end", async () => {
+ const { interactions } = make([item("a"), item("b")]);
+ expect(interactions.dragActive).toBe(false);
+ interactions.dragStart(1);
+ expect(interactions.dragActive).toBe(true);
+ await interactions.dragEnd(0);
+ expect(interactions.dragActive).toBe(false);
+ expect(interactions.dropIndex).toBe(-1);
+ expect(interactions.parentDrop).toBe(false);
+ });
+});
+
+describe("ListInteractions — dragMove (hover decisions)", () => {
+ it("highlights a hovered folder as a move-into target and never swaps with it", () => {
+ const { interactions } = make([item("a"), item("f", { is_folderish: true })]);
+ interactions.dragStart(0);
+ const allow = interactions.dragMove(1); // hovering the folder
+ expect(interactions.dropIndex).toBe(1);
+ // Folders are solid drop targets: sortablejs must not reorder-swap with
+ // one, so it stays put under the pointer and dropping in is reliable.
+ expect(allow).toBe(false);
+ });
+
+ it("never highlights the dragged folder itself (and may reorder past it)", () => {
+ const { interactions } = make([item("f", { is_folderish: true }), item("a")]);
+ interactions.dragStart(0); // grab the folder
+ const allow = interactions.dragMove(0);
+ expect(interactions.dropIndex).toBe(-1);
+ expect(allow).toBe(true);
+ });
+
+ it("never highlights a non-folder row as a move-into target (reorder there)", () => {
+ const { interactions } = make([item("a"), item("b")]);
+ interactions.dragStart(0);
+ const allow = interactions.dragMove(1);
+ expect(interactions.dropIndex).toBe(-1);
+ expect(allow).toBe(true); // manual-order mode allows the reorder
+ });
+
+ it("lets the parent placeholder win and keeps the list still", () => {
+ const { interactions } = make([item("a"), item("f", { is_folderish: true })]);
+ interactions.dragStart(0);
+ interactions.onParentDragEnter(dragEvent()); // parentDrop set by the up-card
+ const allow = interactions.dragMove(1); // even over a folder
+ expect(allow).toBe(false);
+ expect(interactions.dropIndex).toBe(-1);
+ });
+
+ it("does not allow a reorder when not in manual-order mode", () => {
+ const { interactions, contents } = make([item("a"), item("b")]);
+ contents.sortOn = "modified";
+ interactions.dragStart(0);
+ const allow = interactions.dragMove(1);
+ expect(allow).toBe(false);
+ expect(interactions.dropIndex).toBe(-1);
+ });
+});
+
+describe("ListInteractions — dragEnd (moves & reorder)", () => {
+ beforeEach(() => {
+ // Dropping into a folder confirms first; default to accepting.
+ window.confirm = jest.fn(() => true);
+ });
+
+ it("moves a single dragged row into a folder and clears the selection", async () => {
+ const { interactions, contents, selection } = make([
+ item("a"),
+ item("f", { is_folderish: true }),
+ ]);
+ interactions.dragStart(0);
+ interactions.dragMove(1); // highlight the folder (central band)
+ await interactions.dragEnd(0); // no reorder happened → delta 0
+ expect(contents.moveIntoFolder).toHaveBeenCalledWith(
+ "http://nohost/plone/folder/f",
+ ["http://nohost/plone/folder/a"]
+ );
+ expect(selection.clear).toHaveBeenCalled();
+ expect(interactions.dragActive).toBe(false);
+ });
+
+ it("does not move into a folder when the confirmation is declined", async () => {
+ window.confirm = jest.fn(() => false);
+ const { interactions, contents, selection } = make([
+ item("a"),
+ item("f", { is_folderish: true }),
+ ]);
+ interactions.dragStart(0);
+ interactions.dragMove(1);
+ await interactions.dragEnd(0);
+ expect(window.confirm).toHaveBeenCalled();
+ expect(contents.moveIntoFolder).not.toHaveBeenCalled();
+ expect(selection.clear).not.toHaveBeenCalled();
+ });
+
+ it("confirms the folder move through the dialog store when present", async () => {
+ const confirm = makeConfirm(true);
+ const { interactions, contents } = make(
+ [item("a"), item("f", { is_folderish: true })],
+ makeSelection(),
+ makeClipboard(),
+ makeUpload(),
+ confirm
+ );
+ interactions.dragStart(0);
+ interactions.dragMove(1);
+ await interactions.dragEnd(0);
+ expect(confirm.ask).toHaveBeenCalled();
+ expect(contents.moveIntoFolder).toHaveBeenCalled();
+ });
+
+ it("aborts the folder move when the dialog store is declined", async () => {
+ const confirm = makeConfirm(false);
+ const { interactions, contents } = make(
+ [item("a"), item("f", { is_folderish: true })],
+ makeSelection(),
+ makeClipboard(),
+ makeUpload(),
+ confirm
+ );
+ interactions.dragStart(0);
+ interactions.dragMove(1);
+ await interactions.dragEnd(0);
+ expect(confirm.ask).toHaveBeenCalled();
+ expect(contents.moveIntoFolder).not.toHaveBeenCalled();
+ });
+
+ it("moves the whole selection into a folder when the dragged row is selected", async () => {
+ const selection = makeSelection();
+ selection.isSelected.mockReturnValue(true);
+ selection.count = 3;
+ selection.urls = ["u1", "u2", "u3"];
+ const { interactions, contents } = make(
+ [item("a"), item("f", { is_folderish: true })],
+ selection
+ );
+ interactions.dragStart(0);
+ interactions.dragMove(1);
+ await interactions.dragEnd(0);
+ expect(contents.moveIntoFolder).toHaveBeenCalledWith(
+ "http://nohost/plone/folder/f",
+ ["u1", "u2", "u3"]
+ );
+ });
+
+ it("moves the dragged item into the parent on a parent-placeholder drop, then clears selection", async () => {
+ const confirm = makeConfirm(true);
+ const { interactions, contents, selection } = make(
+ [item("a"), item("b")],
+ makeSelection(),
+ makeClipboard(),
+ makeUpload(),
+ confirm
+ );
+ interactions.dragStart(0);
+ interactions.onParentDragEnter(dragEvent());
+ expect(interactions.parentDrop).toBe(true);
+ // The up-card drop is a no-op marker; dragEnd commits the parent move.
+ await interactions.onParentDrop(dragEvent());
+ await interactions.dragEnd(0);
+ expect(confirm.ask).toHaveBeenCalled();
+ expect(contents.moveIntoFolder).toHaveBeenCalledWith("http://nohost/plone", [
+ "http://nohost/plone/folder/a",
+ ]);
+ expect(selection.clear).toHaveBeenCalled();
+ expect(interactions.parentDrop).toBe(false);
+ });
+
+ it("aborts the parent move when the confirmation is declined", async () => {
+ const confirm = makeConfirm(false);
+ const { interactions, contents } = make(
+ [item("a"), item("b")],
+ makeSelection(),
+ makeClipboard(),
+ makeUpload(),
+ confirm
+ );
+ interactions.dragStart(0);
+ interactions.onParentDragEnter(dragEvent());
+ await interactions.dragEnd(0);
+ expect(contents.moveIntoFolder).not.toHaveBeenCalled();
+ });
+
+ it("commits a reorder to the dragged row's new slot in manual-order mode", async () => {
+ const { interactions, contents } = make([item("a"), item("b")]);
+ interactions.dragStart(0);
+ interactions.dragMove(1); // reorder hover past b
+ await interactions.dragEnd(1); // sortablejs moved a one slot down
+ expect(contents.moveTo).toHaveBeenCalledWith("a", 1, ["a", "b"]);
+ expect(contents.moveIntoFolder).not.toHaveBeenCalled();
+ });
+
+ it("commits a backward reorder with a negative delta", async () => {
+ const { interactions, contents } = make([item("a"), item("b"), item("c")]);
+ interactions.dragStart(2); // grab c
+ interactions.dragMove(0);
+ await interactions.dragEnd(-2); // c moved to the top
+ expect(contents.moveTo).toHaveBeenCalledWith("c", -2, ["a", "b", "c"]);
+ });
+
+ it("does not reorder when not in manual-order mode", async () => {
+ const { interactions, contents } = make([item("a"), item("b")]);
+ contents.sortOn = "modified";
+ interactions.dragStart(0);
+ await interactions.dragEnd(1);
+ expect(contents.moveTo).not.toHaveBeenCalled();
+ });
+
+ it("is a no-op when the row did not move (delta 0)", async () => {
+ const { interactions, contents } = make([item("a"), item("b")]);
+ interactions.dragStart(1);
+ await interactions.dragEnd(0);
+ expect(contents.moveTo).not.toHaveBeenCalled();
+ expect(contents.moveIntoFolder).not.toHaveBeenCalled();
+ });
+
+ it("is a no-op when no drag is in progress", async () => {
+ const { interactions, contents } = make([item("a"), item("b")]);
+ await interactions.dragEnd(1);
+ expect(contents.moveTo).not.toHaveBeenCalled();
+ expect(contents.moveIntoFolder).not.toHaveBeenCalled();
+ });
+});
+
+describe("ListInteractions — parent placeholder highlight", () => {
+ it("clears the parent highlight on drag leave", () => {
+ const { interactions } = make([item("a")]);
+ interactions.dragStart(0);
+ interactions.onParentDragEnter(dragEvent());
+ interactions.onParentDragLeave();
+ expect(interactions.parentDrop).toBe(false);
+ });
+
+ it("keeps the parent highlight lit across dragover (re-affirmed each event)", () => {
+ // A dragleave onto the placeholder's children clears parentDrop; the
+ // continuous dragover stream must restore it so the target stays lit.
+ const { interactions } = make([item("a")]);
+ interactions.dragStart(0);
+ interactions.onParentDragEnter(dragEvent());
+ interactions.onParentDragLeave(); // crossed onto a child → cleared
+ expect(interactions.parentDrop).toBe(false);
+ const event = dragEvent();
+ interactions.onParentDragOver(event);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(interactions.parentDrop).toBe(true);
+ });
+
+ it("uploads dropped files into the parent folder (no internal drag)", async () => {
+ const { interactions, upload } = make([item("a")]);
+ await interactions.onParentDrop(dragEvent([{ name: "x.txt" }]));
+ expect(upload.uploadFiles).toHaveBeenCalledWith(
+ [{ name: "x.txt" }],
+ "http://nohost/plone"
+ );
+ });
+});
+
+describe("ListInteractions — external file drags", () => {
+ it("highlights a subfolder row and allows the drop while dragging files", () => {
+ const { interactions } = make([item("a"), item("f", { is_folderish: true })]);
+ const event = dragEvent();
+ interactions.onRowDragOver(event, 1);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(event.dataTransfer!.dropEffect).toBe("copy");
+ expect(interactions.fileDropIndex).toBe(1);
+ });
+
+ it("does not highlight or claim a non-folder row for file drags", () => {
+ const { interactions } = make([item("a"), item("f", { is_folderish: true })]);
+ const event = dragEvent();
+ interactions.onRowDragOver(event, 0);
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ expect(interactions.fileDropIndex).toBe(-1);
+ });
+
+ it("drops the highlight when the file drag moves onto a non-folder row", () => {
+ const { interactions } = make([item("a"), item("f", { is_folderish: true })]);
+ interactions.onRowDragEnter(dragEvent(), 1);
+ expect(interactions.fileDropIndex).toBe(1);
+ interactions.onRowDragEnter(dragEvent(), 0);
+ expect(interactions.fileDropIndex).toBe(-1);
+ });
+
+ it("uploads files dropped onto a subfolder into that folder", async () => {
+ const { interactions, upload } = make([
+ item("a"),
+ item("f", { is_folderish: true }),
+ ]);
+ const event = dragEvent([{ name: "pic.png" }]);
+ await interactions.onRowDrop(event, 1);
+ expect(event.preventDefault).toHaveBeenCalled();
+ expect(upload.uploadFiles).toHaveBeenCalledWith(
+ [{ name: "pic.png" }],
+ "http://nohost/plone/folder/f"
+ );
+ expect(interactions.fileDropIndex).toBe(-1);
+ });
+
+ it("lets a file drop on a non-folder row bubble to the upload zone", async () => {
+ const { interactions, upload } = make([item("a"), item("b")]);
+ const event = dragEvent([{ name: "pic.png" }]);
+ await interactions.onRowDrop(event, 0);
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ expect(upload.uploadFiles).not.toHaveBeenCalled();
+ });
+
+ it("ignores non-file drags in the file handlers", () => {
+ const { interactions } = make([item("f", { is_folderish: true })]);
+ const event = nonFileDragEvent();
+ interactions.onRowDragOver(event, 0);
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ expect(interactions.fileDropIndex).toBe(-1);
+ });
+
+ it("file handlers stand down while a sortablejs item drag is active", () => {
+ const { interactions } = make([item("a"), item("f", { is_folderish: true })]);
+ interactions.dragStart(0);
+ const event = dragEvent();
+ interactions.onRowDragOver(event, 1);
+ // sortablejs owns the internal drag; the file handler does nothing.
+ expect(event.preventDefault).not.toHaveBeenCalled();
+ expect(interactions.fileDropIndex).toBe(-1);
+ });
+});
diff --git a/src/pat/filemanager/src/stores/ModalStore.svelte.ts b/src/pat/filemanager/src/stores/ModalStore.svelte.ts
new file mode 100644
index 000000000..f1d59b2e0
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ModalStore.svelte.ts
@@ -0,0 +1,66 @@
+// Tracks which batch-action modal (if any) is open, plus a shared busy flag so
+// the modal host and forms can disable interaction while a batch operation
+// runs. The modal is a native opened over the listing; this is pure UI
+// state, the actual work lives on ContentsStore.
+
+export interface LinkIntegrityBreach {
+ "@id": string;
+ title: string;
+ uid: string;
+}
+
+export interface LinkIntegrityItem {
+ "@id": string;
+ title: string;
+ breaches: LinkIntegrityBreach[];
+ items_total?: number;
+}
+
+export interface LinkIntegrityData {
+ breaches: LinkIntegrityItem[];
+ /** Sum of items_total across ALL selected items (not just those with breaches). */
+ subItemsTotal: number;
+ onConfirm: () => Promise;
+}
+
+export type ModalName =
+ | "workflow"
+ | "tags"
+ | "properties"
+ | "rename"
+ | "rearrange"
+ | "linkintegrity";
+
+export class ModalStore {
+ active = $state(null);
+ data = $state(null);
+ busy = $state(false);
+
+ get isOpen(): boolean {
+ return this.active !== null;
+ }
+
+ open(name: ModalName, data?: unknown): void {
+ this.active = name;
+ this.data = data ?? null;
+ }
+
+ /** Open the modal, or close it if the same action is already open. */
+ toggle(name: ModalName): void {
+ if (this.busy) return;
+ if (this.active === name) {
+ this.active = null;
+ this.data = null;
+ } else {
+ this.active = name;
+ this.data = null;
+ }
+ }
+
+ /** Close the modal, unless a batch operation is still running. */
+ close(): void {
+ if (this.busy) return;
+ this.active = null;
+ this.data = null;
+ }
+}
diff --git a/src/pat/filemanager/src/stores/ProgressStore.svelte.ts b/src/pat/filemanager/src/stores/ProgressStore.svelte.ts
new file mode 100644
index 000000000..35b32ffd8
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ProgressStore.svelte.ts
@@ -0,0 +1,103 @@
+// Tracks long-running batch operations (copy/move/delete/workflow/tags/…) so
+// the UI can show a progress indicator. Each operation is a task with a label
+// and current/total counters; when total is 0 the bar renders indeterminate
+// (single-request, server-side operations like @copy/@move where the client
+// can't see per-item progress), otherwise it fills as items are processed.
+//
+// A task's `surface` decides *where* it shows: "status" in the status panel
+// (the default — delete/workflow/tags/properties/rename), "dialog" in a
+// self-closing modal (copy/paste), or "folder" as a busy overlay on the
+// drop-target folder (drag-into-folder), keyed by `targetUrl`.
+
+export type ProgressSurface = "status" | "dialog" | "folder";
+
+export interface ProgressTask {
+ id: number;
+ label: string;
+ current: number;
+ total: number;
+ surface: ProgressSurface;
+ /** The folder url a "folder"-surface task is moving items into. */
+ targetUrl?: string;
+}
+
+export interface ProgressOptions {
+ surface?: ProgressSurface;
+ targetUrl?: string;
+}
+
+/** Reports progress of a batch operation: items processed so far / total. */
+export type ProgressFn = (current: number, total: number) => void;
+
+export class ProgressStore {
+ tasks = $state([]);
+ private seq = 0;
+
+ get active(): boolean {
+ return this.tasks.length > 0;
+ }
+
+ /** Tasks shown in the status panel (the default surface). */
+ get statusTasks(): ProgressTask[] {
+ return this.tasks.filter((t) => t.surface === "status");
+ }
+
+ /** Tasks shown in the self-closing progress dialog (copy/paste). */
+ get dialogTasks(): ProgressTask[] {
+ return this.tasks.filter((t) => t.surface === "dialog");
+ }
+
+ /** The folder-surface task moving into `url`, if any (drag-into-folder). */
+ folderTask(url: string): ProgressTask | undefined {
+ return this.tasks.find((t) => t.surface === "folder" && t.targetUrl === url);
+ }
+
+ start(label: string, total = 0, opts: ProgressOptions = {}): number {
+ const id = (this.seq += 1);
+ this.tasks = [
+ ...this.tasks,
+ {
+ id,
+ label,
+ current: 0,
+ total,
+ surface: opts.surface ?? "status",
+ targetUrl: opts.targetUrl,
+ },
+ ];
+ return id;
+ }
+
+ update(id: number, current: number, total: number): void {
+ this.tasks = this.tasks.map((t) =>
+ t.id === id ? { ...t, current, total } : t
+ );
+ }
+
+ finish(id: number): void {
+ this.tasks = this.tasks.filter((t) => t.id !== id);
+ }
+
+ /**
+ * Run a long operation as a tracked task: start a task labelled `label`,
+ * hand `fn` an `onProgress(current, total)` callback to report step counts,
+ * and remove the task once the operation settles (success or error). `opts`
+ * picks the surface (status panel / dialog / folder overlay).
+ */
+ async track(
+ label: string,
+ fn: (onProgress: ProgressFn) => Promise,
+ opts: ProgressOptions = {}
+ ): Promise {
+ const id = this.start(label, 0, opts);
+ try {
+ return await fn((current, total) => this.update(id, current, total));
+ } finally {
+ this.finish(id);
+ }
+ }
+
+ clear(): void {
+ this.tasks = [];
+ }
+}
diff --git a/src/pat/filemanager/src/stores/ProgressStore.test.ts b/src/pat/filemanager/src/stores/ProgressStore.test.ts
new file mode 100644
index 000000000..aa9a0850d
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ProgressStore.test.ts
@@ -0,0 +1,129 @@
+import { ProgressStore } from "./ProgressStore.svelte";
+
+describe("ProgressStore", () => {
+ it("starts tasks with incrementing ids and zeroed counters", () => {
+ const progress = new ProgressStore();
+ const id = progress.start("Deleting…", 3);
+ expect(id).toBe(1);
+ expect(progress.active).toBe(true);
+ expect(progress.tasks).toEqual([
+ {
+ id: 1,
+ label: "Deleting…",
+ current: 0,
+ total: 3,
+ surface: "status",
+ targetUrl: undefined,
+ },
+ ]);
+ });
+
+ it("defaults total to 0 for indeterminate tasks", () => {
+ const progress = new ProgressStore();
+ progress.start("Copying…");
+ expect(progress.tasks[0].total).toBe(0);
+ });
+
+ it("updates a task's current and total by id", () => {
+ const progress = new ProgressStore();
+ const id = progress.start("Deleting…", 3);
+ progress.update(id, 2, 3);
+ expect(progress.tasks[0]).toEqual({
+ id,
+ label: "Deleting…",
+ current: 2,
+ total: 3,
+ surface: "status",
+ targetUrl: undefined,
+ });
+ });
+
+ it("finishes a single task by id, leaving the rest", () => {
+ const progress = new ProgressStore();
+ const a = progress.start("a");
+ progress.start("b");
+ progress.finish(a);
+ expect(progress.tasks.map((t) => t.label)).toEqual(["b"]);
+ expect(progress.active).toBe(true);
+ });
+
+ it("is inactive once all tasks finish", () => {
+ const progress = new ProgressStore();
+ const id = progress.start("a");
+ progress.finish(id);
+ expect(progress.active).toBe(false);
+ });
+
+ it("track() runs fn with a progress callback and clears the task afterwards", async () => {
+ const progress = new ProgressStore();
+ const seen: Array<[number, number]> = [];
+ const result = await progress.track("Working…", async (onProgress) => {
+ expect(progress.active).toBe(true);
+ onProgress(1, 2);
+ seen.push([progress.tasks[0].current, progress.tasks[0].total]);
+ onProgress(2, 2);
+ seen.push([progress.tasks[0].current, progress.tasks[0].total]);
+ return "ok";
+ });
+ expect(result).toBe("ok");
+ expect(seen).toEqual([
+ [1, 2],
+ [2, 2],
+ ]);
+ expect(progress.active).toBe(false);
+ });
+
+ it("track() clears the task even when fn throws", async () => {
+ const progress = new ProgressStore();
+ await expect(
+ progress.track("Working…", async () => {
+ throw new Error("boom");
+ })
+ ).rejects.toThrow("boom");
+ expect(progress.active).toBe(false);
+ });
+
+ it("clears all tasks", () => {
+ const progress = new ProgressStore();
+ progress.start("a");
+ progress.start("b");
+ progress.clear();
+ expect(progress.tasks).toEqual([]);
+ });
+
+ it("defaults the surface to status", () => {
+ const progress = new ProgressStore();
+ progress.start("a");
+ expect(progress.tasks[0].surface).toBe("status");
+ });
+
+ it("partitions tasks by surface", () => {
+ const progress = new ProgressStore();
+ progress.start("del", 2);
+ progress.start("paste", 0, { surface: "dialog" });
+ progress.start("move", 0, {
+ surface: "folder",
+ targetUrl: "http://nohost/plone/folder",
+ });
+ expect(progress.statusTasks.map((t) => t.label)).toEqual(["del"]);
+ expect(progress.dialogTasks.map((t) => t.label)).toEqual(["paste"]);
+ expect(progress.folderTask("http://nohost/plone/folder")?.label).toBe(
+ "move"
+ );
+ expect(progress.folderTask("http://nohost/plone/other")).toBeUndefined();
+ });
+
+ it("track() forwards surface options to the task", async () => {
+ const progress = new ProgressStore();
+ let surfaceWhileRunning = "";
+ await progress.track(
+ "Copying…",
+ async () => {
+ surfaceWhileRunning = progress.dialogTasks[0]?.surface ?? "";
+ },
+ { surface: "dialog" }
+ );
+ expect(surfaceWhileRunning).toBe("dialog");
+ expect(progress.active).toBe(false);
+ });
+});
diff --git a/src/pat/filemanager/src/stores/SelectionStore.svelte.ts b/src/pat/filemanager/src/stores/SelectionStore.svelte.ts
new file mode 100644
index 000000000..7230059fc
--- /dev/null
+++ b/src/pat/filemanager/src/stores/SelectionStore.svelte.ts
@@ -0,0 +1,141 @@
+import type { ContentsStore, ContentItem } from "./ContentsStore.svelte";
+
+// Tracks which items are selected for batch actions. Two modes:
+// - "page": individually toggled / whole-page checkbox (only loaded items)
+// - "all": everything matching the current query, gathered by a paged
+// UID-only sweep (like the legacy selectAll) so actions can span
+// pages without loading every batch into the table.
+
+export interface SelectedItem {
+ uid: string;
+ url: string;
+ id: string;
+ title: string;
+ isFolderish: boolean;
+ subjects: string[];
+}
+
+export function toSelected(item: ContentItem): SelectedItem {
+ const url = item["@id"];
+ const id = url.split(/[?#]/)[0].replace(/\/+$/, "").split("/").pop() || "";
+ return {
+ uid: (item.UID as string) || url,
+ url,
+ id,
+ title: (item.Title as string) || id,
+ isFolderish: Boolean(item.is_folderish),
+ subjects: Array.isArray(item.Subject) ? (item.Subject as string[]) : [],
+ };
+}
+
+export class SelectionStore {
+ contents: ContentsStore;
+ selected = $state>({});
+ mode = $state<"page" | "all">("page");
+ sweeping = $state(false);
+
+ constructor(contents: ContentsStore) {
+ this.contents = contents;
+ }
+
+ get count(): number {
+ return Object.keys(this.selected).length;
+ }
+
+ get isEmpty(): boolean {
+ return this.count === 0;
+ }
+
+ get items(): SelectedItem[] {
+ return Object.values(this.selected);
+ }
+
+ get urls(): string[] {
+ return this.items.map((it) => it.url);
+ }
+
+ private keyOf(item: ContentItem): string {
+ return (item.UID as string) || item["@id"];
+ }
+
+ isSelected(item: ContentItem): boolean {
+ return this.keyOf(item) in this.selected;
+ }
+
+ /** Are all of the given (page) items currently selected? */
+ allSelected(items: ContentItem[]): boolean {
+ return items.length > 0 && items.every((it) => this.keyOf(it) in this.selected);
+ }
+
+ /** Toggle one item; reverts an "all-in-query" selection to page mode. */
+ toggle(item: ContentItem): void {
+ const sel = toSelected(item);
+ const next = { ...this.selected };
+ if (sel.uid in next) {
+ delete next[sel.uid];
+ } else {
+ next[sel.uid] = sel;
+ }
+ this.selected = next;
+ this.mode = "page";
+ }
+
+ /** Replace the selection with just this item (plain click). */
+ selectOnly(item: ContentItem): void {
+ const sel = toSelected(item);
+ this.selected = { [sel.uid]: sel };
+ this.mode = "page";
+ }
+
+ /** Add the inclusive index range to the selection (shift-click). */
+ selectRange(items: ContentItem[], from: number, to: number): void {
+ const start = Math.max(0, Math.min(from, to));
+ const end = Math.min(items.length - 1, Math.max(from, to));
+ const next = { ...this.selected };
+ for (let i = start; i <= end; i++) {
+ const sel = toSelected(items[i]);
+ next[sel.uid] = sel;
+ }
+ this.selected = next;
+ this.mode = "page";
+ }
+
+ /** Select or deselect every item on the current page. */
+ setPage(items: ContentItem[], checked: boolean): void {
+ const next = { ...this.selected };
+ for (const it of items) {
+ const sel = toSelected(it);
+ if (checked) next[sel.uid] = sel;
+ else delete next[sel.uid];
+ }
+ this.selected = next;
+ this.mode = "page";
+ }
+
+ /** Sweep the whole query (all pages) and select every match. */
+ async selectAllInQuery(): Promise {
+ this.sweeping = true;
+ try {
+ const items = await this.contents.fetchAllMatching([
+ "UID",
+ "is_folderish",
+ "Title",
+ "Subject",
+ ]);
+ const next: Record = {};
+ for (const it of items) {
+ const sel = toSelected(it);
+ next[sel.uid] = sel;
+ }
+ this.selected = next;
+ this.mode = "all";
+ } finally {
+ this.sweeping = false;
+ }
+ }
+
+ clear(): void {
+ this.selected = {};
+ this.mode = "page";
+ }
+}
diff --git a/src/pat/filemanager/src/stores/SelectionStore.test.ts b/src/pat/filemanager/src/stores/SelectionStore.test.ts
new file mode 100644
index 000000000..1c2391089
--- /dev/null
+++ b/src/pat/filemanager/src/stores/SelectionStore.test.ts
@@ -0,0 +1,143 @@
+import { SelectionStore, toSelected } from "./SelectionStore.svelte";
+
+function item(uid: string, extra: Record = {}) {
+ return {
+ "@id": `http://nohost/plone/folder/${uid}`,
+ UID: uid,
+ Title: uid.toUpperCase(),
+ ...extra,
+ };
+}
+
+function makeStore(allMatching: ReturnType[] = []) {
+ const contents = {
+ fetchAllMatching: jest.fn().mockResolvedValue(allMatching),
+ };
+ const store = new SelectionStore(contents as never);
+ return { store, contents };
+}
+
+describe("toSelected", () => {
+ it("derives id, title and folderishness from an item", () => {
+ const sel = toSelected(
+ item("doc-1", { is_folderish: true, Title: "Doc One", Subject: ["x", "y"] })
+ );
+ expect(sel).toEqual({
+ uid: "doc-1",
+ url: "http://nohost/plone/folder/doc-1",
+ id: "doc-1",
+ title: "Doc One",
+ isFolderish: true,
+ subjects: ["x", "y"],
+ });
+ });
+
+ it("defaults subjects to an empty array when Subject is missing", () => {
+ const sel = toSelected(item("doc-1"));
+ expect(sel.subjects).toEqual([]);
+ });
+
+ it("falls back to the @id when UID is missing", () => {
+ const sel = toSelected({ "@id": "http://nohost/plone/folder/x" } as never);
+ expect(sel.uid).toBe("http://nohost/plone/folder/x");
+ expect(sel.id).toBe("x");
+ expect(sel.title).toBe("x");
+ });
+});
+
+describe("SelectionStore", () => {
+ it("toggles a single item on and off", () => {
+ const { store } = makeStore();
+ const it = item("a");
+ store.toggle(it);
+ expect(store.isSelected(it)).toBe(true);
+ expect(store.count).toBe(1);
+ expect(store.urls).toEqual(["http://nohost/plone/folder/a"]);
+ store.toggle(it);
+ expect(store.isSelected(it)).toBe(false);
+ expect(store.isEmpty).toBe(true);
+ });
+
+ it("setPage selects and clears all given items", () => {
+ const { store } = makeStore();
+ const page = [item("a"), item("b")];
+ store.setPage(page, true);
+ expect(store.allSelected(page)).toBe(true);
+ expect(store.count).toBe(2);
+ store.setPage(page, false);
+ expect(store.count).toBe(0);
+ });
+
+ it("allSelected is false for an empty page", () => {
+ const { store } = makeStore();
+ expect(store.allSelected([])).toBe(false);
+ });
+
+ it("selectOnly replaces the selection with a single item", () => {
+ const { store } = makeStore();
+ store.setPage([item("a"), item("b")], true);
+ store.selectOnly(item("c"));
+ expect(store.count).toBe(1);
+ expect(store.isSelected(item("c"))).toBe(true);
+ expect(store.isSelected(item("a"))).toBe(false);
+ expect(store.mode).toBe("page");
+ });
+
+ it("selectRange adds the inclusive index range to the selection", () => {
+ const { store } = makeStore();
+ const page = [item("a"), item("b"), item("c"), item("d")];
+ store.selectRange(page, 1, 2);
+ expect(store.count).toBe(2);
+ expect(store.isSelected(item("b"))).toBe(true);
+ expect(store.isSelected(item("c"))).toBe(true);
+ expect(store.isSelected(item("a"))).toBe(false);
+ });
+
+ it("selectRange normalises reversed and out-of-bounds indices", () => {
+ const { store } = makeStore();
+ const page = [item("a"), item("b"), item("c")];
+ store.selectRange(page, 5, -3);
+ expect(store.count).toBe(3);
+ expect(store.allSelected(page)).toBe(true);
+ });
+
+ it("selectRange merges with the existing selection", () => {
+ const { store } = makeStore();
+ const page = [item("a"), item("b"), item("c")];
+ store.toggle(item("a"));
+ store.selectRange(page, 2, 2);
+ expect(store.count).toBe(2);
+ expect(store.isSelected(item("a"))).toBe(true);
+ expect(store.isSelected(item("c"))).toBe(true);
+ });
+
+ it("selectAllInQuery sweeps and switches to all mode", async () => {
+ const { store, contents } = makeStore([item("a"), item("b"), item("c")]);
+ await store.selectAllInQuery();
+ expect(contents.fetchAllMatching).toHaveBeenCalledWith([
+ "UID",
+ "is_folderish",
+ "Title",
+ "Subject",
+ ]);
+ expect(store.count).toBe(3);
+ expect(store.mode).toBe("all");
+ expect(store.sweeping).toBe(false);
+ });
+
+ it("toggling after an all-sweep reverts to page mode", async () => {
+ const { store } = makeStore([item("a"), item("b")]);
+ await store.selectAllInQuery();
+ store.toggle(item("a"));
+ expect(store.mode).toBe("page");
+ expect(store.count).toBe(1);
+ });
+
+ it("clear empties the selection and resets the mode", async () => {
+ const { store } = makeStore([item("a")]);
+ await store.selectAllInQuery();
+ store.clear();
+ expect(store.isEmpty).toBe(true);
+ expect(store.mode).toBe("page");
+ });
+});
diff --git a/src/pat/filemanager/src/stores/StatusStore.svelte.ts b/src/pat/filemanager/src/stores/StatusStore.svelte.ts
new file mode 100644
index 000000000..16345da11
--- /dev/null
+++ b/src/pat/filemanager/src/stores/StatusStore.svelte.ts
@@ -0,0 +1,42 @@
+// Transient status messages (the replacement for Plone's portal status
+// messages). Batch operations push a success/warning/error line here and the
+// StatusMessages component renders them; entries are dismissible.
+
+export type StatusKind = "info" | "success" | "warning" | "error";
+
+export interface StatusMessage {
+ id: number;
+ kind: StatusKind;
+ text: string;
+}
+
+export class StatusStore {
+ messages = $state([]);
+ private seq = 0;
+
+ add(kind: StatusKind, text: string): void {
+ this.seq += 1;
+ this.messages = [...this.messages, { id: this.seq, kind, text }];
+ }
+
+ info(text: string): void {
+ this.add("info", text);
+ }
+ success(text: string): void {
+ this.add("success", text);
+ }
+ warning(text: string): void {
+ this.add("warning", text);
+ }
+ error(text: string): void {
+ this.add("error", text);
+ }
+
+ dismiss(id: number): void {
+ this.messages = this.messages.filter((m) => m.id !== id);
+ }
+
+ clear(): void {
+ this.messages = [];
+ }
+}
diff --git a/src/pat/filemanager/src/stores/StatusStore.test.ts b/src/pat/filemanager/src/stores/StatusStore.test.ts
new file mode 100644
index 000000000..7cde40dfd
--- /dev/null
+++ b/src/pat/filemanager/src/stores/StatusStore.test.ts
@@ -0,0 +1,29 @@
+import { StatusStore } from "./StatusStore.svelte";
+
+describe("StatusStore", () => {
+ it("adds messages with incrementing ids and the given kind", () => {
+ const status = new StatusStore();
+ status.success("done");
+ status.error("oops");
+ expect(status.messages).toEqual([
+ { id: 1, kind: "success", text: "done" },
+ { id: 2, kind: "error", text: "oops" },
+ ]);
+ });
+
+ it("dismisses a single message by id", () => {
+ const status = new StatusStore();
+ status.info("a");
+ status.warning("b");
+ status.dismiss(1);
+ expect(status.messages).toEqual([{ id: 2, kind: "warning", text: "b" }]);
+ });
+
+ it("clears all messages", () => {
+ const status = new StatusStore();
+ status.info("a");
+ status.info("b");
+ status.clear();
+ expect(status.messages).toEqual([]);
+ });
+});
diff --git a/src/pat/filemanager/src/stores/UploadStore.svelte.ts b/src/pat/filemanager/src/stores/UploadStore.svelte.ts
new file mode 100644
index 000000000..2a19169fd
--- /dev/null
+++ b/src/pat/filemanager/src/stores/UploadStore.svelte.ts
@@ -0,0 +1,198 @@
+import { createFolder, uploadFile } from "../api/upload.js";
+import type { ContentsStore } from "./ContentsStore.svelte";
+import type { DropManifest } from "../utils/dropentries";
+
+// Tracks in-flight uploads for one folder view. Each picked/dropped file gets an
+// entry with live progress; uploadFiles() pushes them to the current folder via
+// the upload api and reloads the listing once done.
+
+export type UploadStatus = "uploading" | "done" | "error";
+
+export interface UploadEntry {
+ id: number;
+ name: string;
+ size: number;
+ loaded: number;
+ status: UploadStatus;
+ error?: string;
+}
+
+export interface UploadResult {
+ ok: number;
+ failed: Array<{ name: string; error: string }>;
+}
+
+export class UploadStore {
+ contents: ContentsStore;
+ entries = $state([]);
+ active = $state(false);
+ private seq = 0;
+
+ constructor(contents: ContentsStore) {
+ this.contents = contents;
+ }
+
+ get totalSize(): number {
+ return this.entries.reduce((sum, e) => sum + e.size, 0);
+ }
+
+ get loadedSize(): number {
+ return this.entries.reduce((sum, e) => sum + e.loaded, 0);
+ }
+
+ /** Overall progress across the current entries, 0..1. */
+ get progress(): number {
+ const total = this.totalSize;
+ return total > 0 ? this.loadedSize / total : 0;
+ }
+
+ private patch(id: number, change: Partial): void {
+ this.entries = this.entries.map((e) => (e.id === id ? { ...e, ...change } : e));
+ }
+
+ /**
+ * Upload the given files, one after another, then reload the listing.
+ * Per-file failures are collected rather than aborting the batch. Targets
+ * the current folder unless `targetUrl` names another container — e.g. a
+ * subfolder the files were dropped directly onto.
+ */
+ async uploadFiles(files: File[], targetUrl?: string): Promise {
+ const list = Array.from(files);
+ if (list.length === 0) return { ok: 0, failed: [] };
+ const folderUrl = targetUrl ?? this.contents.contextUrl;
+
+ const created: UploadEntry[] = list.map((file) => ({
+ id: (this.seq += 1),
+ name: file.name,
+ size: file.size,
+ loaded: 0,
+ status: "uploading",
+ }));
+ this.entries = [...this.entries, ...created];
+ this.active = true;
+
+ const failed: UploadResult["failed"] = [];
+ try {
+ for (let i = 0; i < list.length; i++) {
+ const file = list[i];
+ const entry = created[i];
+ try {
+ await uploadFile(folderUrl, file, {
+ onProgress: (loaded: number) => this.patch(entry.id, { loaded }),
+ });
+ this.patch(entry.id, { status: "done", loaded: file.size });
+ } catch (e) {
+ const error = (e as Error).message;
+ this.patch(entry.id, { status: "error", error });
+ failed.push({ name: file.name, error });
+ }
+ }
+ await this.contents.load();
+ } finally {
+ this.active = false;
+ }
+ return { ok: list.length - failed.length, failed };
+ }
+
+ /**
+ * Recreate a dropped folder tree under `targetUrl` and upload its files.
+ * Folders are created parents-first (`manifest.dirs` is already ordered) and
+ * each created container's real `@id` is mapped by its relative path, so a
+ * Plone-normalised id never breaks the mapping of children. Files then
+ * upload into their mapped folder url, reusing the same per-file `entries`
+ * progress UI as `uploadFiles`. Folder-create failures surface as error
+ * entries and orphan their descendants (those files error out too) without
+ * aborting the rest of the batch. The listing reloads once at the end.
+ */
+ async uploadTree(
+ targetUrl: string,
+ manifest: DropManifest,
+ folderType = "Folder"
+ ): Promise {
+ const failed: UploadResult["failed"] = [];
+ const urlByPath = new Map([["", targetUrl]]);
+ this.active = true;
+ try {
+ // 1. Recreate the folder structure, parents before children.
+ for (const dir of manifest.dirs) {
+ const segs = dir.split("/");
+ const name = segs[segs.length - 1];
+ const parentUrl = urlByPath.get(segs.slice(0, -1).join("/"));
+ if (!parentUrl) {
+ // Parent folder failed earlier → can't place this one either.
+ this.pushError(dir, `Parent folder for "${dir}" was not created`);
+ failed.push({
+ name: dir,
+ error: `Parent folder for "${dir}" was not created`,
+ });
+ continue;
+ }
+ try {
+ const created = await createFolder(parentUrl, {
+ title: name,
+ type: folderType,
+ });
+ urlByPath.set(dir, created["@id"]);
+ } catch (e) {
+ const error = (e as Error).message;
+ this.pushError(dir, error);
+ failed.push({ name: dir, error });
+ }
+ }
+
+ // 2. Upload each file into its (recreated) folder.
+ const created: UploadEntry[] = manifest.files.map((df) => ({
+ id: (this.seq += 1),
+ name: df.path.length ? `${df.path.join("/")}/${df.file.name}` : df.file.name,
+ size: df.file.size,
+ loaded: 0,
+ status: "uploading",
+ }));
+ this.entries = [...this.entries, ...created];
+
+ for (let i = 0; i < manifest.files.length; i++) {
+ const { path, file } = manifest.files[i];
+ const entry = created[i];
+ const folderUrl = urlByPath.get(path.join("/"));
+ if (!folderUrl) {
+ const error = `Folder "${path.join("/")}" was not created`;
+ this.patch(entry.id, { status: "error", error });
+ failed.push({ name: entry.name, error });
+ continue;
+ }
+ try {
+ await uploadFile(folderUrl, file, {
+ onProgress: (loaded: number) => this.patch(entry.id, { loaded }),
+ });
+ this.patch(entry.id, { status: "done", loaded: file.size });
+ } catch (e) {
+ const error = (e as Error).message;
+ this.patch(entry.id, { status: "error", error });
+ failed.push({ name: entry.name, error });
+ }
+ }
+ await this.contents.load();
+ } finally {
+ this.active = false;
+ }
+ const total = manifest.dirs.length + manifest.files.length;
+ return { ok: total - failed.length, failed };
+ }
+
+ /** Record a failed folder creation as an error entry in the progress panel. */
+ private pushError(name: string, error: string): void {
+ this.entries = [
+ ...this.entries,
+ { id: (this.seq += 1), name, size: 0, loaded: 0, status: "error", error },
+ ];
+ }
+
+ /** Drop finished entries, keeping any still uploading. */
+ clearFinished(): void {
+ this.entries = this.entries.filter((e) => e.status === "uploading");
+ }
+
+ clear(): void {
+ this.entries = [];
+ }
+}
diff --git a/src/pat/filemanager/src/stores/UploadStore.test.ts b/src/pat/filemanager/src/stores/UploadStore.test.ts
new file mode 100644
index 000000000..b685ec712
--- /dev/null
+++ b/src/pat/filemanager/src/stores/UploadStore.test.ts
@@ -0,0 +1,186 @@
+import { UploadStore } from "./UploadStore.svelte";
+import { uploadFile, createFolder } from "../api/upload.js";
+import type { DropManifest } from "../utils/dropentries";
+
+jest.mock("../api/upload.js", () => ({
+ uploadFile: jest.fn().mockResolvedValue(null),
+ createFolder: jest.fn(),
+}));
+
+const mockedUpload = uploadFile as jest.Mock;
+const mockedCreateFolder = createFolder as jest.Mock;
+
+function makeContents() {
+ return {
+ contextUrl: "http://nohost/plone/folder",
+ load: jest.fn().mockResolvedValue(undefined),
+ };
+}
+
+function makeFile(name: string, size: number, type = "text/plain") {
+ return { name, size, type } as unknown as File;
+}
+
+beforeEach(() => {
+ mockedUpload.mockReset();
+ mockedUpload.mockResolvedValue(null);
+ mockedCreateFolder.mockReset();
+ // Default: echo a created folder url derived from parent + title.
+ mockedCreateFolder.mockImplementation((parent: string, { title }) =>
+ Promise.resolve({ "@id": `${parent}/${title}` })
+ );
+});
+
+function makeManifest(files: DropManifest["files"], dirs: string[]): DropManifest {
+ return {
+ files,
+ dirs,
+ fileCount: files.length,
+ folderCount: dirs.length,
+ totalSize: files.reduce((s, f) => s + f.file.size, 0),
+ hasDirectories: dirs.length > 0,
+ };
+}
+
+describe("UploadStore", () => {
+ it("uploads each file to the current folder and reloads", async () => {
+ const contents = makeContents();
+ const store = new UploadStore(contents as never);
+ const result = await store.uploadFiles([
+ makeFile("a.txt", 10),
+ makeFile("b.txt", 20),
+ ]);
+
+ expect(mockedUpload).toHaveBeenCalledTimes(2);
+ expect(mockedUpload.mock.calls[0][0]).toBe("http://nohost/plone/folder");
+ expect(mockedUpload.mock.calls[0][1].name).toBe("a.txt");
+ expect(contents.load).toHaveBeenCalledTimes(1);
+ expect(result).toEqual({ ok: 2, failed: [] });
+ expect(store.active).toBe(false);
+ expect(store.entries.every((e) => e.status === "done")).toBe(true);
+ });
+
+ it("uploads into a given target folder instead of the current one", async () => {
+ const contents = makeContents();
+ const store = new UploadStore(contents as never);
+ await store.uploadFiles(
+ [makeFile("a.txt", 10)],
+ "http://nohost/plone/folder/sub"
+ );
+ expect(mockedUpload.mock.calls[0][0]).toBe("http://nohost/plone/folder/sub");
+ expect(contents.load).toHaveBeenCalledTimes(1);
+ });
+
+ it("records per-file failures without aborting the batch", async () => {
+ mockedUpload
+ .mockRejectedValueOnce(new Error("boom"))
+ .mockResolvedValueOnce(null);
+ const contents = makeContents();
+ const store = new UploadStore(contents as never);
+ const result = await store.uploadFiles([
+ makeFile("bad.txt", 5),
+ makeFile("good.txt", 5),
+ ]);
+
+ expect(result.ok).toBe(1);
+ expect(result.failed).toEqual([{ name: "bad.txt", error: "boom" }]);
+ expect(store.entries.find((e) => e.name === "bad.txt")?.status).toBe("error");
+ expect(store.entries.find((e) => e.name === "good.txt")?.status).toBe("done");
+ // a failed upload still triggers a reload
+ expect(contents.load).toHaveBeenCalledTimes(1);
+ });
+
+ it("tracks progress per entry via the onProgress callback", async () => {
+ mockedUpload.mockImplementation((_url, file, opts) => {
+ opts.onProgress(file.size);
+ return Promise.resolve(null);
+ });
+ const contents = makeContents();
+ const store = new UploadStore(contents as never);
+ await store.uploadFiles([makeFile("a.txt", 42)]);
+ expect(store.entries[0].loaded).toBe(42);
+ expect(store.progress).toBe(1);
+ });
+
+ it("does nothing for an empty file list", async () => {
+ const contents = makeContents();
+ const store = new UploadStore(contents as never);
+ const result = await store.uploadFiles([]);
+ expect(result).toEqual({ ok: 0, failed: [] });
+ expect(mockedUpload).not.toHaveBeenCalled();
+ expect(contents.load).not.toHaveBeenCalled();
+ });
+
+ it("clearFinished keeps only still-uploading entries", async () => {
+ const contents = makeContents();
+ const store = new UploadStore(contents as never);
+ await store.uploadFiles([makeFile("a.txt", 5)]);
+ expect(store.entries).toHaveLength(1);
+ store.clearFinished();
+ expect(store.entries).toHaveLength(0);
+ });
+});
+
+describe("UploadStore.uploadTree", () => {
+ it("creates folders parents-first and uploads files into them", async () => {
+ const contents = makeContents();
+ const store = new UploadStore(contents as never);
+ const target = "http://nohost/plone/folder";
+ const manifest = makeManifest(
+ [
+ { path: ["MyFolder"], file: makeFile("readme.txt", 10) },
+ { path: ["MyFolder", "img"], file: makeFile("a.png", 20) },
+ ],
+ ["MyFolder", "MyFolder/img"]
+ );
+
+ const result = await store.uploadTree(target, manifest);
+
+ // Folders created parents-first, each under its mapped parent url.
+ expect(mockedCreateFolder).toHaveBeenCalledTimes(2);
+ expect(mockedCreateFolder.mock.calls[0][0]).toBe(target);
+ expect(mockedCreateFolder.mock.calls[0][1]).toEqual({
+ title: "MyFolder",
+ type: "Folder",
+ });
+ expect(mockedCreateFolder.mock.calls[1][0]).toBe(`${target}/MyFolder`);
+
+ // Files uploaded into their recreated folder urls.
+ expect(mockedUpload.mock.calls[0][0]).toBe(`${target}/MyFolder`);
+ expect(mockedUpload.mock.calls[1][0]).toBe(`${target}/MyFolder/img`);
+
+ expect(contents.load).toHaveBeenCalledTimes(1);
+ expect(result).toEqual({ ok: 4, failed: [] });
+ expect(store.active).toBe(false);
+ });
+
+ it("passes a custom folder type through to createFolder", async () => {
+ const store = new UploadStore(makeContents() as never);
+ const manifest = makeManifest([], ["F"]);
+ await store.uploadTree("http://nohost/plone/folder", manifest, "myfolder");
+ expect(mockedCreateFolder.mock.calls[0][1].type).toBe("myfolder");
+ });
+
+ it("orphans descendants when a folder fails, without aborting the batch", async () => {
+ // "MyFolder" fails to create → its child folder and files can't be placed.
+ mockedCreateFolder.mockImplementation((parent: string, { title }) => {
+ if (title === "MyFolder") return Promise.reject(new Error("boom"));
+ return Promise.resolve({ "@id": `${parent}/${title}` });
+ });
+ const store = new UploadStore(makeContents() as never);
+ const manifest = makeManifest(
+ [{ path: ["MyFolder"], file: makeFile("readme.txt", 10) }],
+ ["MyFolder", "MyFolder/img"]
+ );
+
+ const result = await store.uploadTree("http://nohost/plone/folder", manifest);
+
+ // No file upload attempted (its folder never existed).
+ expect(mockedUpload).not.toHaveBeenCalled();
+ // Both the failed folder, its orphaned child folder, and the file fail.
+ expect(result.ok).toBe(0);
+ expect(result.failed).toHaveLength(3);
+ // The failure still surfaced as error entries in the progress panel.
+ expect(store.entries.some((e) => e.status === "error")).toBe(true);
+ });
+});
diff --git a/src/pat/filemanager/src/stores/ViewStore.svelte.ts b/src/pat/filemanager/src/stores/ViewStore.svelte.ts
new file mode 100644
index 000000000..9647b4595
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ViewStore.svelte.ts
@@ -0,0 +1,58 @@
+import { cookieStorage, type KeyValueStore } from "../utils/storage";
+import type { ConfigStore } from "./ConfigStore.svelte";
+
+// Which listing view is rendered. The batch layer (toolbar, filter, pagination,
+// upload, modals) is view-independent, so switching only swaps the rendered
+// component. `available` is a list so a future "miller" view (reusing
+// pat-contentbrowser) slots in without reworking the switcher (see spec §20.8).
+
+export type ViewMode = "table" | "grid";
+
+// Grid image scale: five discrete stages driven by the size slider. The values
+// double as the CSS class suffix (`.grid-size-xs|s|m|l|xl`) that picks the card
+// min-width, so the slider stays pure presentation with no inline styles.
+export type GridScale = "xs" | "s" | "m" | "l" | "xl";
+
+export class ViewStore {
+ config: ConfigStore;
+ private storage: KeyValueStore | null;
+ available: ViewMode[] = ["table", "grid"];
+ scales: GridScale[] = ["xs", "s", "m", "l", "xl"];
+ mode = $state("table");
+ gridScale = $state("m");
+
+ constructor(config: ConfigStore, storageKey = "pat-filemanager") {
+ this.config = config;
+ this.storage = storageKey ? cookieStorage(storageKey) : null;
+ // Seed order: cookie → config.defaultView → "table".
+ const saved = this.storage?.get("view");
+ this.mode = this.isValid(saved)
+ ? saved
+ : this.isValid(config.defaultView)
+ ? config.defaultView
+ : "table";
+ // Grid scale is a pure cookie preference (medium by default).
+ const savedScale = this.storage?.get("gridScale");
+ if (this.isScale(savedScale)) this.gridScale = savedScale;
+ }
+
+ private isValid(mode: unknown): mode is ViewMode {
+ return typeof mode === "string" && this.available.includes(mode as ViewMode);
+ }
+
+ private isScale(scale: unknown): scale is GridScale {
+ return typeof scale === "string" && this.scales.includes(scale as GridScale);
+ }
+
+ setMode(mode: ViewMode): void {
+ if (!this.isValid(mode) || mode === this.mode) return;
+ this.mode = mode;
+ this.storage?.set("view", mode);
+ }
+
+ setGridScale(scale: GridScale): void {
+ if (!this.isScale(scale) || scale === this.gridScale) return;
+ this.gridScale = scale;
+ this.storage?.set("gridScale", scale);
+ }
+}
diff --git a/src/pat/filemanager/src/stores/ViewStore.test.ts b/src/pat/filemanager/src/stores/ViewStore.test.ts
new file mode 100644
index 000000000..cb7d15ac3
--- /dev/null
+++ b/src/pat/filemanager/src/stores/ViewStore.test.ts
@@ -0,0 +1,89 @@
+import Cookies from "js-cookie";
+import { ConfigStore } from "./ConfigStore.svelte";
+import { ViewStore } from "./ViewStore.svelte";
+
+function makeConfig(defaultView?: string) {
+ return new ConfigStore({
+ contextUrl: "http://nohost/plone/folder",
+ defaultView,
+ });
+}
+
+beforeEach(() => {
+ for (const name of Object.keys(Cookies.get())) {
+ Cookies.remove(name, { path: "/" });
+ }
+});
+
+describe("ViewStore", () => {
+ it("defaults to the table view", () => {
+ const store = new ViewStore(makeConfig(), "");
+ expect(store.mode).toBe("table");
+ expect(store.available).toEqual(["table", "grid"]);
+ });
+
+ it("seeds the mode from config.defaultView", () => {
+ const store = new ViewStore(makeConfig("grid"), "");
+ expect(store.mode).toBe("grid");
+ });
+
+ it("ignores an invalid config.defaultView", () => {
+ const store = new ViewStore(makeConfig("bogus"), "");
+ expect(store.mode).toBe("table");
+ });
+
+ it("setMode switches and ignores unknown modes", () => {
+ const store = new ViewStore(makeConfig(), "");
+ store.setMode("grid");
+ expect(store.mode).toBe("grid");
+ store.setMode("bogus" as never);
+ expect(store.mode).toBe("grid");
+ });
+
+ it("persists to and restores from a cookie", () => {
+ const first = new ViewStore(makeConfig(), "pat-filemanager");
+ first.setMode("grid");
+ const second = new ViewStore(makeConfig(), "pat-filemanager");
+ expect(second.mode).toBe("grid");
+ });
+
+ it("cookie takes precedence over config.defaultView", () => {
+ const first = new ViewStore(makeConfig(), "pat-filemanager");
+ first.setMode("grid");
+ const second = new ViewStore(makeConfig("table"), "pat-filemanager");
+ expect(second.mode).toBe("grid");
+ });
+
+ it("ignores a stale or invalid cookie value", () => {
+ Cookies.set("pat-filemanager:view", JSON.stringify("bogus"), { path: "/" });
+ const store = new ViewStore(makeConfig(), "pat-filemanager");
+ expect(store.mode).toBe("table");
+ });
+
+ it("defaults the grid scale to medium", () => {
+ const store = new ViewStore(makeConfig(), "");
+ expect(store.gridScale).toBe("m");
+ expect(store.scales).toEqual(["xs", "s", "m", "l", "xl"]);
+ });
+
+ it("setGridScale switches and ignores unknown scales", () => {
+ const store = new ViewStore(makeConfig(), "");
+ store.setGridScale("xl");
+ expect(store.gridScale).toBe("xl");
+ store.setGridScale("huge" as never);
+ expect(store.gridScale).toBe("xl");
+ });
+
+ it("persists and restores the grid scale from a cookie", () => {
+ const first = new ViewStore(makeConfig(), "pat-filemanager");
+ first.setGridScale("xs");
+ const second = new ViewStore(makeConfig(), "pat-filemanager");
+ expect(second.gridScale).toBe("xs");
+ });
+
+ it("ignores a stale or invalid grid-scale cookie value", () => {
+ Cookies.set("pat-filemanager:gridScale", JSON.stringify("huge"), { path: "/" });
+ const store = new ViewStore(makeConfig(), "pat-filemanager");
+ expect(store.gridScale).toBe("m");
+ });
+});
diff --git a/src/pat/filemanager/src/utils/batch.ts b/src/pat/filemanager/src/utils/batch.ts
new file mode 100644
index 000000000..7df0308c7
--- /dev/null
+++ b/src/pat/filemanager/src/utils/batch.ts
@@ -0,0 +1,27 @@
+import type { BatchResult } from "../stores/ContentsStore.svelte";
+import type { StatusStore } from "../stores/StatusStore.svelte";
+import { _t } from "./i18n.ts";
+
+/**
+ * Push success / failure status lines for a finished batch operation.
+ *
+ * @param status the StatusStore to report into
+ * @param result the {ok, failed} summary from a ContentsStore batch method
+ * @param done translated success template with a `${count}` placeholder
+ * @param action translated failure template with `${count}`/`${details}`
+ */
+export function reportBatch(
+ status: StatusStore,
+ result: BatchResult,
+ done: string,
+ action: string
+): void {
+ if (result.ok > 0) {
+ status.success(_t(done, { count: result.ok }));
+ }
+ if (result.failed.length) {
+ const n = result.failed.length;
+ const details = result.failed.map((f) => `${f.title} (${f.error})`).join("; ");
+ status.error(_t(action, { count: n, details }));
+ }
+}
diff --git a/src/pat/filemanager/src/utils/dismiss.ts b/src/pat/filemanager/src/utils/dismiss.ts
new file mode 100644
index 000000000..344938742
--- /dev/null
+++ b/src/pat/filemanager/src/utils/dismiss.ts
@@ -0,0 +1,54 @@
+import type { Action } from "svelte/action";
+
+interface DismissParams {
+ /** Only listen while the popover is open. */
+ enabled: boolean;
+ /** Called on Escape or a pointer/focus event outside the node. */
+ onClose: () => void;
+}
+
+/**
+ * Svelte action: close a popover when the user presses Escape or interacts
+ * outside the node. Attach to the *wrapper* that contains both the toggle and
+ * the popover, so activating the toggle counts as "inside".
+ *
+ * Listeners are only bound while `enabled` is true (popover open), so closed
+ * popovers — of which there can be many, one per table row — cost nothing.
+ */
+export const dismiss: Action = (node, params) => {
+ let { enabled, onClose } = params;
+
+ function onKeydown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ event.stopPropagation();
+ onClose();
+ }
+ }
+
+ function onPointerDown(event: Event) {
+ if (!node.contains(event.target as Node)) onClose();
+ }
+
+ function activate() {
+ document.addEventListener("keydown", onKeydown, true);
+ document.addEventListener("pointerdown", onPointerDown, true);
+ }
+
+ function deactivate() {
+ document.removeEventListener("keydown", onKeydown, true);
+ document.removeEventListener("pointerdown", onPointerDown, true);
+ }
+
+ if (enabled) activate();
+
+ return {
+ update(next: DismissParams) {
+ const wasEnabled = enabled;
+ enabled = next.enabled;
+ onClose = next.onClose;
+ if (enabled && !wasEnabled) activate();
+ else if (!enabled && wasEnabled) deactivate();
+ },
+ destroy: deactivate,
+ };
+};
diff --git a/src/pat/filemanager/src/utils/dropentries.test.ts b/src/pat/filemanager/src/utils/dropentries.test.ts
new file mode 100644
index 000000000..3904bc60e
--- /dev/null
+++ b/src/pat/filemanager/src/utils/dropentries.test.ts
@@ -0,0 +1,116 @@
+import {
+ captureDropEntries,
+ entriesHaveDirectory,
+ readDropManifest,
+} from "./dropentries";
+
+// Build fake FileSystemEntry objects mirroring the (non-standard) entries API.
+function fileEntry(name: string, size: number) {
+ return {
+ isFile: true,
+ isDirectory: false,
+ name,
+ file: (ok: (f: File) => void) => ok({ name, size } as unknown as File),
+ };
+}
+
+/**
+ * A directory entry whose reader returns its children in `batches` successive
+ * readEntries() calls, then an empty batch — exercising the pagination drain.
+ */
+function dirEntry(name: string, children: unknown[], batches = 1) {
+ return {
+ isFile: false,
+ isDirectory: true,
+ name,
+ createReader() {
+ const chunks: unknown[][] = [];
+ const per = Math.ceil(children.length / batches) || 0;
+ for (let i = 0; i < children.length; i += per || 1) {
+ chunks.push(children.slice(i, i + (per || 1)));
+ }
+ let call = 0;
+ return {
+ readEntries(ok: (e: unknown[]) => void) {
+ ok(chunks[call++] || []);
+ },
+ };
+ },
+ };
+}
+
+describe("dropentries", () => {
+ describe("captureDropEntries", () => {
+ it("maps items through webkitGetAsEntry and skips nulls", () => {
+ const a = fileEntry("a.txt", 1);
+ const dataTransfer = {
+ items: [
+ { webkitGetAsEntry: () => a },
+ { webkitGetAsEntry: () => null },
+ {}, // no webkitGetAsEntry → ignored
+ ],
+ } as unknown as DataTransfer;
+ expect(captureDropEntries(dataTransfer)).toEqual([a]);
+ });
+
+ it("returns [] without items or dataTransfer (no entries API)", () => {
+ expect(captureDropEntries(null)).toEqual([]);
+ expect(captureDropEntries({} as DataTransfer)).toEqual([]);
+ });
+ });
+
+ describe("entriesHaveDirectory", () => {
+ it("is true only when an entry is a directory", () => {
+ expect(entriesHaveDirectory([fileEntry("a", 1)] as never)).toBe(false);
+ expect(entriesHaveDirectory([dirEntry("d", [])] as never)).toBe(true);
+ });
+ });
+
+ describe("readDropManifest", () => {
+ it("walks a nested tree, recording dirs parents-first with file paths", async () => {
+ // MyFolder/
+ // readme.txt (10)
+ // img/
+ // a.png (20)
+ // b.png (30)
+ const tree = dirEntry("MyFolder", [
+ fileEntry("readme.txt", 10),
+ dirEntry("img", [fileEntry("a.png", 20), fileEntry("b.png", 30)]),
+ ]);
+ const manifest = await readDropManifest([tree] as never);
+
+ expect(manifest.hasDirectories).toBe(true);
+ expect(manifest.dirs).toEqual(["MyFolder", "MyFolder/img"]);
+ expect(manifest.folderCount).toBe(2);
+ expect(manifest.fileCount).toBe(3);
+ expect(manifest.totalSize).toBe(60);
+
+ const byName = Object.fromEntries(
+ manifest.files.map((f) => [f.file.name, f.path.join("/")])
+ );
+ expect(byName["readme.txt"]).toBe("MyFolder");
+ expect(byName["a.png"]).toBe("MyFolder/img");
+ expect(byName["b.png"]).toBe("MyFolder/img");
+ });
+
+ it("drains a paginated directory reader fully", async () => {
+ const tree = dirEntry(
+ "Big",
+ [fileEntry("1", 1), fileEntry("2", 1), fileEntry("3", 1)],
+ 3 // three readEntries() batches before the empty terminator
+ );
+ const manifest = await readDropManifest([tree] as never);
+ expect(manifest.fileCount).toBe(3);
+ });
+
+ it("keeps loose root files at an empty path", async () => {
+ const manifest = await readDropManifest([
+ fileEntry("loose.txt", 5),
+ dirEntry("F", [fileEntry("x", 1)]),
+ ] as never);
+ const loose = manifest.files.find((f) => f.file.name === "loose.txt");
+ expect(loose?.path).toEqual([]);
+ expect(manifest.dirs).toEqual(["F"]);
+ });
+ });
+});
diff --git a/src/pat/filemanager/src/utils/dropentries.ts b/src/pat/filemanager/src/utils/dropentries.ts
new file mode 100644
index 000000000..8792be0d6
--- /dev/null
+++ b/src/pat/filemanager/src/utils/dropentries.ts
@@ -0,0 +1,137 @@
+// Reading dropped OS folders from a native drop event.
+//
+// `event.dataTransfer.files` is a flat FileList that silently omits any dropped
+// directories. To recreate a dropped folder tree we instead read
+// `dataTransfer.items` through the (non-standard but universally shipped)
+// `DataTransferItem.webkitGetAsEntry()` API, which yields FileSystemEntry
+// objects we can walk recursively. The capture must happen synchronously during
+// the drop event (the items list is only live then); the returned entry objects
+// stay valid for the async walk afterwards.
+
+/** One file found in the drop, with its folder path relative to the drop root. */
+export interface DroppedFile {
+ /** Folder segments, e.g. ["MyFolder", "img"]; empty for a loose root file. */
+ path: string[];
+ file: File;
+}
+
+/** The full picture of a folder drop: what to create and what to upload. */
+export interface DropManifest {
+ files: DroppedFile[];
+ /** Relative folder paths ("MyFolder", "MyFolder/img"), parents before children. */
+ dirs: string[];
+ fileCount: number;
+ folderCount: number;
+ totalSize: number;
+ /** True when the drop contained at least one directory. */
+ hasDirectories: boolean;
+}
+
+// Minimal structural types for the entries API (lib.dom's typings vary across
+// TS versions, so we describe just what we use rather than rely on globals).
+interface FsEntry {
+ isFile: boolean;
+ isDirectory: boolean;
+ name: string;
+}
+interface FsFileEntry extends FsEntry {
+ file(success: (file: File) => void, error?: (err: unknown) => void): void;
+}
+interface FsDirectoryEntry extends FsEntry {
+ createReader(): FsDirectoryReader;
+}
+interface FsDirectoryReader {
+ readEntries(success: (entries: FsEntry[]) => void, error?: (err: unknown) => void): void;
+}
+
+/**
+ * Synchronously capture the top-level FileSystemEntry objects from a drop.
+ * Call this from the drop handler's synchronous prefix — `dataTransfer.items`
+ * is only readable while the event is being dispatched. Returns [] in browsers
+ * without the entries API, so callers fall back to the flat-file path.
+ */
+export function captureDropEntries(dataTransfer: DataTransfer | null): FsEntry[] {
+ const items = dataTransfer?.items;
+ if (!items) return [];
+ const entries: FsEntry[] = [];
+ for (const item of Array.from(items)) {
+ // webkitGetAsEntry is non-standard; guard its presence.
+ const get = (item as DataTransferItem & {
+ webkitGetAsEntry?: () => FsEntry | null;
+ }).webkitGetAsEntry;
+ const entry = get ? get.call(item) : null;
+ if (entry) entries.push(entry);
+ }
+ return entries;
+}
+
+/** Whether any captured top-level entry is a directory (→ folder-drop flow). */
+export function entriesHaveDirectory(entries: FsEntry[]): boolean {
+ return entries.some((e) => e?.isDirectory);
+}
+
+/** Promisified FileSystemFileEntry.file(). */
+function readFile(entry: FsFileEntry): Promise {
+ return new Promise((resolve, reject) => entry.file(resolve, reject));
+}
+
+/**
+ * Read all children of a directory entry. The DirectoryReader is paginated:
+ * each readEntries() returns a batch (often 100), and an empty batch signals the
+ * end — so we keep calling until it drains.
+ */
+async function readDir(entry: FsDirectoryEntry): Promise {
+ const reader = entry.createReader();
+ const all: FsEntry[] = [];
+ for (;;) {
+ const batch = await new Promise((resolve, reject) =>
+ reader.readEntries(resolve, reject)
+ );
+ if (batch.length === 0) break;
+ all.push(...batch);
+ }
+ return all;
+}
+
+/**
+ * Walk the captured entries into a DropManifest: every nested file (with its
+ * relative folder path) plus every folder to create, parents-before-children.
+ */
+export async function readDropManifest(entries: FsEntry[]): Promise {
+ const files: DroppedFile[] = [];
+ const dirs: string[] = [];
+ const seenDirs = new Set();
+ let totalSize = 0;
+ const hasDirectories = entriesHaveDirectory(entries);
+
+ async function walk(entry: FsEntry, prefix: string[]): Promise {
+ if (entry.isFile) {
+ const file = await readFile(entry as FsFileEntry);
+ files.push({ path: prefix, file });
+ totalSize += file.size || 0;
+ return;
+ }
+ if (entry.isDirectory) {
+ const dirPath = [...prefix, entry.name];
+ const key = dirPath.join("/");
+ // Record the folder before descending → parents land before children.
+ if (!seenDirs.has(key)) {
+ seenDirs.add(key);
+ dirs.push(key);
+ }
+ const children = await readDir(entry as FsDirectoryEntry);
+ for (const child of children) await walk(child, dirPath);
+ }
+ }
+
+ for (const entry of entries) await walk(entry, []);
+
+ return {
+ files,
+ dirs,
+ fileCount: files.length,
+ folderCount: dirs.length,
+ totalSize,
+ hasDirectories,
+ };
+}
diff --git a/src/pat/filemanager/src/utils/format.ts b/src/pat/filemanager/src/utils/format.ts
new file mode 100644
index 000000000..145d254e4
--- /dev/null
+++ b/src/pat/filemanager/src/utils/format.ts
@@ -0,0 +1,61 @@
+// Presentation helpers. Dates are formatted with the real Intl API (the catalog
+// already sorts them as dates), not string-munged.
+
+const MISSING = new Set(["1969/12/31 19:00:00 US/Eastern", "1969/12/31", "None", ""]);
+
+export function formatDate(value: unknown): string {
+ if (value == null || typeof value !== "string" || MISSING.has(value)) return "";
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "";
+ return new Intl.DateTimeFormat(undefined, {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(date);
+}
+
+export function formatSize(value: unknown): string {
+ if (typeof value === "string") return value;
+ if (typeof value !== "number" || !Number.isFinite(value)) return "";
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let size = value;
+ let unit = 0;
+ while (size >= 1024 && unit < units.length - 1) {
+ size /= 1024;
+ unit += 1;
+ }
+ return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
+}
+
+/**
+ * Resolve a thumbnail url from an item's image_scales metadata.
+ *
+ * `scale` may be a single scale name or a fallback chain (first present wins):
+ * the table asks for "thumb", the grid for a larger ["preview","mini","thumb"].
+ * Falls back to the full-size original only when none of the wanted scales exist.
+ * Returns null when the item has no preview image.
+ */
+export function thumbnailUrl(
+ item: Record,
+ scale: string | string[] = "thumb",
+ field = "image"
+): string | null {
+ const scales = item?.image_scales as Record | undefined;
+ const entry = scales?.[field]?.[0];
+ if (!entry) return null;
+ const base = (item["@id"] as string)?.replace(/\/+$/, "") || "";
+ const wanted = Array.isArray(scale) ? scale : [scale];
+ let download: string | undefined;
+ for (const name of wanted) {
+ const candidate = entry.scales?.[name]?.download;
+ if (candidate) {
+ download = candidate;
+ break;
+ }
+ }
+ if (!download) download = entry.download;
+ if (!download) return null;
+ return /^https?:\/\//.test(download) ? download : `${base}/${download}`;
+}
diff --git a/src/pat/filemanager/src/utils/i18n.ts b/src/pat/filemanager/src/utils/i18n.ts
new file mode 100644
index 000000000..23408bfe7
--- /dev/null
+++ b/src/pat/filemanager/src/utils/i18n.ts
@@ -0,0 +1,10 @@
+// i18n bridge: route every user-facing string through the patternslib i18n
+// singleton (the "widgets" domain, same catalog pat-structure uses). Keep the
+// `${name}` placeholder syntax of core/i18n — keywords are substituted there.
+
+// @ts-expect-error — core/i18n-wrapper is plain JS without type declarations.
+import translate from "../../../../core/i18n-wrapper";
+
+export function _t(msgid: string, keywords?: Record): string {
+ return translate(msgid, keywords);
+}
diff --git a/src/pat/filemanager/src/utils/js-cookie.d.ts b/src/pat/filemanager/src/utils/js-cookie.d.ts
new file mode 100644
index 000000000..12dbfca30
--- /dev/null
+++ b/src/pat/filemanager/src/utils/js-cookie.d.ts
@@ -0,0 +1,23 @@
+// Minimal ambient declaration for js-cookie v3. The installed package ships no
+// types and there is no @types/js-cookie in this repo, so this covers just the
+// subset the filemanager uses (get/set/remove).
+declare module "js-cookie" {
+ export interface CookieAttributes {
+ expires?: number | Date;
+ path?: string;
+ domain?: string;
+ secure?: boolean;
+ sameSite?: "strict" | "lax" | "none" | "Strict" | "Lax" | "None";
+ [property: string]: unknown;
+ }
+
+ interface CookiesStatic {
+ get(): { [key: string]: string };
+ get(name: string): string | undefined;
+ set(name: string, value: string, options?: CookieAttributes): string | undefined;
+ remove(name: string, options?: CookieAttributes): void;
+ }
+
+ const Cookies: CookiesStatic;
+ export default Cookies;
+}
diff --git a/src/pat/filemanager/src/utils/sortable.ts b/src/pat/filemanager/src/utils/sortable.ts
new file mode 100644
index 000000000..e392dbef9
--- /dev/null
+++ b/src/pat/filemanager/src/utils/sortable.ts
@@ -0,0 +1,76 @@
+import Sortable from "sortablejs";
+import type { SortableEvent, MoveEvent } from "sortablejs";
+import type { ListInteractions } from "../stores/ListInteractions.svelte";
+
+export interface SortableParams {
+ interactions: ListInteractions;
+}
+
+/**
+ * Svelte action that turns the listing container (table `` / grid ``)
+ * into a sortablejs list. sortablejs owns the drag gesture and its animation;
+ * all the decisions — reorder vs move-into-folder vs move-into-parent — live in
+ * the shared `ListInteractions` controller, which this action drives through
+ * three hooks:
+ *
+ * - `dragStart(index)` when a drag begins,
+ * - `dragMove(relatedIndex)` on each hover (returns whether sortablejs may
+ * reorder-swap; a folder hover holds the list still and highlights it as a
+ * move-into target),
+ * - `dragEnd(delta)` on drop, which commits the reorder or move.
+ *
+ * Because Svelte owns the listing via a keyed `{#each}`, the action reverts
+ * sortablejs's DOM move in `onEnd` before the controller mutates the model — the
+ * re-render then lays the rows out in their committed order, so Svelte's view of
+ * the DOM never drifts from the real DOM.
+ */
+export function sortableList(node: HTMLElement, params: SortableParams) {
+ let interactions = params.interactions;
+ // The dragged element's original next sibling, captured at drag start, so
+ // the DOM can be restored to the order Svelte still believes in.
+ let origNextSibling: Node | null = null;
+
+ const sortable = Sortable.create(node, {
+ // Only listing items drag; the grid's "up to parent" placeholder and any
+ // loading/empty message rows lack the marker and stay put.
+ draggable: "[data-fm-item]",
+ // Links, buttons, the checkbox and its label keep their own behaviour
+ // (matching ListInteractions.isInteractive); a drag starts anywhere else.
+ filter: "a, button, input, label",
+ preventOnFilter: false,
+ // Reuse the existing dragged-item styling.
+ chosenClass: "dragging",
+ ghostClass: "filemanager-drag-ghost",
+ animation: 150,
+ onStart(evt: SortableEvent) {
+ origNextSibling = evt.item.nextSibling;
+ // Draggable index, so the grid's non-draggable "up to parent"
+ // placeholder doesn't shift the model index by one.
+ interactions.dragStart(evt.oldDraggableIndex ?? -1);
+ },
+ onMove(evt: MoveEvent) {
+ const relIndexRaw = Number(evt.related?.dataset?.fmIndex);
+ const relIndex = Number.isInteger(relIndexRaw) ? relIndexRaw : -1;
+ return interactions.dragMove(relIndex);
+ },
+ onEnd(evt: SortableEvent) {
+ const delta = (evt.newDraggableIndex ?? 0) - (evt.oldDraggableIndex ?? 0);
+ // Undo sortablejs's DOM move so Svelte stays the source of truth; the
+ // model mutation in dragEnd re-renders the list in the new order.
+ if (evt.item && evt.from) {
+ evt.from.insertBefore(evt.item, origNextSibling);
+ }
+ origNextSibling = null;
+ void interactions.dragEnd(delta);
+ },
+ });
+
+ return {
+ update(next: SortableParams) {
+ interactions = next.interactions;
+ },
+ destroy() {
+ sortable.destroy();
+ },
+ };
+}
diff --git a/src/pat/filemanager/src/utils/sortablejs.d.ts b/src/pat/filemanager/src/utils/sortablejs.d.ts
new file mode 100644
index 000000000..458c83d91
--- /dev/null
+++ b/src/pat/filemanager/src/utils/sortablejs.d.ts
@@ -0,0 +1,45 @@
+// Minimal ambient types for sortablejs (the package ships no declarations).
+// Only the surface the filemanager's `sortableList` action touches is typed.
+declare module "sortablejs" {
+ export interface SortableEvent {
+ item: HTMLElement;
+ from: HTMLElement;
+ to: HTMLElement;
+ oldIndex?: number;
+ newIndex?: number;
+ // Index counted over only the elements matching `draggable` (so the
+ // grid's non-draggable "up to parent" placeholder is excluded), which is
+ // what maps to the model index in `data-fm-index`.
+ oldDraggableIndex?: number;
+ newDraggableIndex?: number;
+ }
+
+ export interface MoveEvent {
+ related: HTMLElement;
+ relatedRect: DOMRect;
+ dragged: HTMLElement;
+ draggedRect: DOMRect;
+ willInsertAfter?: boolean;
+ }
+
+ export interface SortableOptions {
+ draggable?: string;
+ filter?: string;
+ preventOnFilter?: boolean;
+ handle?: string;
+ chosenClass?: string;
+ ghostClass?: string;
+ dragClass?: string;
+ animation?: number;
+ sort?: boolean;
+ onStart?: (event: SortableEvent) => void;
+ onEnd?: (event: SortableEvent) => void;
+ onMove?: (event: MoveEvent, originalEvent: Event) => boolean | number | void;
+ }
+
+ export default class Sortable {
+ static create(el: HTMLElement, options?: SortableOptions): Sortable;
+ option(name: string, value?: unknown): unknown;
+ destroy(): void;
+ }
+}
diff --git a/src/pat/filemanager/src/utils/storage.ts b/src/pat/filemanager/src/utils/storage.ts
new file mode 100644
index 000000000..161343d0e
--- /dev/null
+++ b/src/pat/filemanager/src/utils/storage.ts
@@ -0,0 +1,40 @@
+import Cookies, { type CookieAttributes } from "js-cookie";
+
+// Cookie-backed key/value store mirroring the patternslib `store.local`
+// interface (get/set/remove). Values are JSON-serialized under a
+// `${prefix}:${name}` cookie so user preferences (batch size, visible columns)
+// survive reloads and travel with the request to the server, matching the
+// cookie-based settings the legacy pat-structure used.
+
+const COOKIE_ATTRS: CookieAttributes = {
+ path: "/",
+ expires: 365,
+ sameSite: "Lax",
+};
+
+export interface KeyValueStore {
+ get(name: string): unknown;
+ set(name: string, value: unknown): void;
+ remove(name: string): void;
+}
+
+export function cookieStorage(prefix: string): KeyValueStore {
+ const key = (name: string) => `${prefix}:${name}`;
+ return {
+ get(name) {
+ const raw = Cookies.get(key(name));
+ if (raw === undefined) return undefined;
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return undefined;
+ }
+ },
+ set(name, value) {
+ Cookies.set(key(name), JSON.stringify(value), COOKIE_ATTRS);
+ },
+ remove(name) {
+ Cookies.remove(key(name), { path: COOKIE_ATTRS.path });
+ },
+ };
+}
diff --git a/src/patterns.js b/src/patterns.js
index 96800ba2f..341b1c467 100644
--- a/src/patterns.js
+++ b/src/patterns.js
@@ -23,6 +23,7 @@ import "./pat/contentloader/contentloader";
import "./pat/contentbrowser/contentbrowser";
import "./pat/cookietrigger/cookietrigger";
import "./pat/datatables/datatables";
+import "./pat/filemanager/filemanager";
import "./pat/formautofocus/formautofocus";
import "./pat/formunloadalert/formunloadalert";
import "./pat/livesearch/livesearch";
diff --git a/svelte.config.js b/svelte.config.js
index b29ec1339..6172484d3 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -1,5 +1,8 @@
+import sveltePreprocess from "svelte-preprocess";
+
/** @type {import('svelte/compiler').CompileOptions} */
const config = {
+ preprocess: sveltePreprocess(),
compilerOptions: {
runes: true,
},
diff --git a/tools/jest-svelte-component.cjs b/tools/jest-svelte-component.cjs
new file mode 100644
index 000000000..34e0e987f
--- /dev/null
+++ b/tools/jest-svelte-component.cjs
@@ -0,0 +1,42 @@
+// Jest transformer for Svelte 5 components (.svelte).
+//
+// svelte-jester refuses to run while Jest is in CJS mode, which is the mode the
+// Patternslib base jest config uses — so component-mount tests are otherwise
+// impossible here. This mirrors tools/jest-svelte-module.cjs: compile the
+// component to client JS with the svelte compiler and down-convert the emitted
+// ESM to CommonJS so Jest's default runtime can require it.
+//
+// The filemanager components use plain JS (JSDoc) in their