diff --git a/package-lock.json b/package-lock.json index e6af9de62..bb81ea4b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2305,6 +2305,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.609.0.tgz", "integrity": "sha512-0bNPAyPdkWkS9EGB2A9BZDkBNrnVCBzk5lYRezoT4K3/gi9w1DTYH5tuRdwaTZdxW19U1mq7CV0YJJARKO1L9Q==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -2358,6 +2359,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.609.0.tgz", "integrity": "sha512-A0B3sDKFoFlGo8RYRjDBWHXpbgirer2bZBkCIzhSPHc1vOFHt/m2NcUoE2xnBKXJFrptL1xDkvo1P+XYp/BfcQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -4982,7 +4984,6 @@ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -5025,7 +5026,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -5066,7 +5066,6 @@ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -5084,7 +5083,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -5151,7 +5149,6 @@ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" @@ -5166,7 +5163,6 @@ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", @@ -5260,7 +5256,6 @@ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -5271,7 +5266,6 @@ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" @@ -6017,6 +6011,7 @@ "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -6053,6 +6048,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6065,6 +6061,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz", "integrity": "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6089,6 +6086,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" @@ -6132,6 +6130,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6255,6 +6254,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6267,6 +6267,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6282,6 +6283,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6418,7 +6420,6 @@ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -8275,6 +8276,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -8472,6 +8474,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -9976,6 +9979,7 @@ "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10502,6 +10506,7 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -10934,6 +10939,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11821,8 +11827,7 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cookie": { "version": "0.7.2", @@ -12508,7 +12513,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1400418.tgz", "integrity": "sha512-U8j75zDOXF8IP3o0Cgb7K4tFA9uUHEOru2Wx64+EUqL4LNOh9dRe1i8WKR1k3mSpjcCe3aIkTDvEwq0YkI4hfw==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "7.0.0", @@ -12820,6 +12826,7 @@ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -13050,6 +13057,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -14421,7 +14429,6 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -16386,7 +16393,6 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -16829,7 +16835,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -16866,7 +16871,6 @@ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -19645,6 +19649,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -21575,7 +21580,6 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -21586,8 +21590,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -22691,6 +22694,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -24213,7 +24217,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/turndown": { "version": "7.2.4", @@ -24284,6 +24289,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -27232,6 +27238,7 @@ "integrity": "sha512-SyrSVpygEdPzvgpapVZRQCy8XIOecadp56bPQewpfSfo9ypB6wdOUkx13NBu2ANDlUAtJX7KaLJpTtywVHNlVw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "^22.2.0", "@wdio/config": "8.46.0", @@ -27312,6 +27319,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -27361,6 +27369,7 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -27437,6 +27446,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -27681,6 +27691,7 @@ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -27791,8 +27802,7 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/yargs": { "version": "17.7.2", diff --git a/package.json b/package.json index 3ad7be401..5baaa6422 100644 --- a/package.json +++ b/package.json @@ -941,11 +941,32 @@ "description": "Text direction for the editor (left-to-right or right-to-left)" }, "codex-editor-extension.cellsPerPage": { + "title": "Cells Per Page", "type": "number", "default": 50, "minimum": 5, "maximum": 200, - "description": "Number of cells to display per page when a chapter has many cells. Helps with performance for large chapters." + "description": "Default page size for milestones without custom subdivision breaks. Applies only as a fallback when a milestone has no user-defined subdivisions." + }, + "codex-editor-extension.useSubdivisionNumberLabels": { + "title": "Always Show Subdivision Number Ranges", + "type": "boolean", + "default": false, + "description": "When enabled, milestone subdivisions always display their numeric cell range (e.g. '6-15') even if a user-assigned name exists. When disabled, names take precedence and the range is shown only as a muted suffix." + }, + "codex-editor-extension.maxSubdivisionLength": { + "title": "Maximum Subdivision Length", + "type": "number", + "default": 0, + "minimum": 0, + "maximum": 5000, + "description": "Maximum number of cells the editor will leave inside a single subdivision. Stretches between user-defined breaks that exceed this length are sub-chunked using 'Cells Per Page'. Set to 0 to disable (in which case 'Cells Per Page' itself acts as the threshold). Useful when you want a higher 'Cells Per Page' default but still allow short logical pages between custom breaks to remain unbroken." + }, + "codex-editor-extension.enableMilestonePlacementEditing": { + "title": "Enable Milestone Placement Editing", + "type": "boolean", + "default": false, + "description": "When enabled, the milestone accordion's settings mode reveals controls for editing where milestones sit in the source document — adding, removing, promoting a subdivision break to a milestone, or demoting a milestone to a subdivision break. Edits made on the source mirror to the paired target by UUID." }, "codex-editor-extension.useOnlyValidatedExamples": { "title": "Use Only Validated Examples", diff --git a/src/interfaceSettings/interfaceSettings.ts b/src/interfaceSettings/interfaceSettings.ts index 04faa39c1..9ffbde4dc 100644 --- a/src/interfaceSettings/interfaceSettings.ts +++ b/src/interfaceSettings/interfaceSettings.ts @@ -62,11 +62,25 @@ export async function openInterfaceSettings() { const sendInit = () => { const config = vscode.workspace.getConfiguration("codex-editor-extension"); const highlightSearchResults = config.get("highlightSearchResults", true); + const cellsPerPage = config.get("cellsPerPage", 50); + const useSubdivisionNumberLabels = config.get( + "useSubdivisionNumberLabels", + false + ); + const maxSubdivisionLength = config.get("maxSubdivisionLength", 0); + const enableMilestonePlacementEditing = config.get( + "enableMilestonePlacementEditing", + false + ); panel.webview.postMessage({ command: "init", data: { highlightSearchResults, + cellsPerPage, + useSubdivisionNumberLabels, + maxSubdivisionLength, + enableMilestonePlacementEditing, }, }); }; @@ -99,6 +113,79 @@ export async function openInterfaceSettings() { ); break; } + + case "updateCellsPerPage": { + // Clamp to the range declared in package.json so invalid input + // cannot corrupt pagination. Pull bounds from the schema-defined + // minimum/maximum rather than hardcoding them in multiple places. + const raw = Number(message.value); + if (!Number.isFinite(raw)) break; + const clamped = Math.max(5, Math.min(200, Math.round(raw))); + const config = vscode.workspace.getConfiguration("codex-editor-extension"); + await config.update( + "cellsPerPage", + clamped, + vscode.ConfigurationTarget.Workspace + ); + break; + } + + case "updateUseSubdivisionNumberLabels": { + const config = vscode.workspace.getConfiguration("codex-editor-extension"); + await config.update( + "useSubdivisionNumberLabels", + Boolean(message.value), + vscode.ConfigurationTarget.Workspace + ); + break; + } + + case "updateMaxSubdivisionLength": { + // 0 means "off" — the resolver falls back to using `cellsPerPage` + // as the threshold. Anything else is clamped to the package.json + // bounds so corrupted input can't sneak through. + const raw = Number(message.value); + if (!Number.isFinite(raw)) break; + const rounded = Math.round(raw); + const clamped = rounded <= 0 ? 0 : Math.max(0, Math.min(5000, rounded)); + const config = vscode.workspace.getConfiguration("codex-editor-extension"); + await config.update( + "maxSubdivisionLength", + clamped, + vscode.ConfigurationTarget.Workspace + ); + break; + } + + case "updateEnableMilestonePlacementEditing": { + const config = vscode.workspace.getConfiguration("codex-editor-extension"); + await config.update( + "enableMilestonePlacementEditing", + Boolean(message.value), + vscode.ConfigurationTarget.Workspace + ); + break; + } + } + }); + + // Keep the panel in sync when settings change from elsewhere (e.g. the + // VS Code Settings UI). Disposed together with the panel below. + const configListener = vscode.workspace.onDidChangeConfiguration((e) => { + if ( + e.affectsConfiguration("codex-editor-extension.highlightSearchResults") || + e.affectsConfiguration("codex-editor-extension.cellsPerPage") || + e.affectsConfiguration("codex-editor-extension.useSubdivisionNumberLabels") || + e.affectsConfiguration("codex-editor-extension.maxSubdivisionLength") || + e.affectsConfiguration( + "codex-editor-extension.enableMilestonePlacementEditing" + ) + ) { + sendInit(); } }); + + panel.onDidDispose(() => { + configListener.dispose(); + }); } diff --git a/src/projectManager/utils/migrationUtils.ts b/src/projectManager/utils/migrationUtils.ts index ecbc059aa..278a06dfb 100644 --- a/src/projectManager/utils/migrationUtils.ts +++ b/src/projectManager/utils/migrationUtils.ts @@ -1,6 +1,5 @@ import * as vscode from "vscode"; import * as path from "path"; -import { randomUUID } from "crypto"; import * as dugiteGit from "../../utils/dugiteGit"; import { CodexContentSerializer } from "@/serializer"; import { vrefData } from "@/utils/verseRefUtils/verseData"; @@ -11,13 +10,16 @@ import { getAuthApi } from "../../extension"; import { extractParentCellIdFromParatext } from "../../providers/codexCellEditorProvider/utils/cellUtils"; import { generateCellIdFromHash, isUuidFormat } from "../../utils/uuidUtils"; import { getCorrespondingSourceUri, getCorrespondingCodexUri } from "../../utils/codexNotebookUtils"; +import { + buildMilestoneCellPayload, + extractChapterFromCellId, +} from "../../utils/milestoneCellUtils"; import { parseVerseRef, getSortKeyFromParsedRef, stripCellIdSuffix, type ParsedVerseRef, } from "../../utils/verseRefUtils"; -import bibleData from "../../../webviews/codex-webviews/src/assets/bible-books-lookup.json"; import { resolveCodexCustomMerge, mergeDuplicateCellsUsingResolverLogic } from "./merge/resolvers"; import { atomicWriteUriText } from "../../utils/notebookSafeSaveUtils"; import { normalizeNotebookFileText, formatJsonForNotebookFile } from "../../utils/notebookFileFormattingUtils"; @@ -2010,112 +2012,6 @@ async function getCurrentUserName(): Promise { return "unknown"; } -/** - * Extracts the chapter/section number from a cellId. - * Handles formats like: - * - "GEN 1:1" -> "1" - * - "Book Name 2:5" -> "2" - * - "filename 1:1" -> "1" - * Returns null if the pattern doesn't match. - */ -function extractChapterFromCellId(cellId: string): string | null { - if (!cellId) return null; - - // Pattern: anything followed by space, then number, colon, number - // e.g., "GEN 1:1", "Book Name 2:5", "filename 1:1" - const match = cellId.match(/\s+(\d+):(\d+)(?::|$)/); - if (match) { - return match[1]; // Return the chapter number (first number) - } - return null; -} - -/** - * Extracts chapter number from a cell using priority order: - * 1. metadata.chapterNumber (Biblica) - * 2. metadata.chapter (USFM) - * 3. metadata.data?.chapter (legacy) - * 4. extractChapterFromCellId (from cellId) - * 5. milestoneIndex (final fallback, 1-indexed) - */ -function extractChapterFromCell(cell: any, milestoneIndex: number): string { - // Priority 1: metadata.chapterNumber (Biblica) - if (cell?.metadata?.chapterNumber !== undefined && cell.metadata.chapterNumber !== null) { - return String(cell.metadata.chapterNumber); - } - - // Priority 2: metadata.chapter (USFM) - if (cell?.metadata?.chapter !== undefined && cell.metadata.chapter !== null) { - return String(cell.metadata.chapter); - } - - // Priority 3: metadata.data?.chapter (legacy) - if (cell?.metadata?.data?.chapter !== undefined && cell.metadata.data.chapter !== null) { - return String(cell.metadata.data.chapter); - } - - // Priority 4: Extract from cellId - const cellId = cell?.metadata?.id || cell?.id; - if (cellId) { - const chapterFromId = extractChapterFromCellId(cellId); - if (chapterFromId) { - return chapterFromId; - } - } - - // Priority 5: Use milestone index (1-indexed) - return milestoneIndex.toString(); -} - -/** - * Extracts book abbreviation from a cell's globalReferences or cellMarkers. - * Returns null if no book abbreviation can be found. - */ -function extractBookNameFromCell(cell: any): string | null { - // Priority 1: Extract from globalReferences array (preferred method) - const globalRefs = cell?.data?.globalReferences || cell?.metadata?.data?.globalReferences; - if (globalRefs && Array.isArray(globalRefs) && globalRefs.length > 0) { - const firstRef = globalRefs[0]; - // Extract book name: "GEN 1:1" -> "GEN" or "TheChosen-201-en-SingleSpeaker 1:jkflds" -> "TheChosen-201-en-SingleSpeaker" - const bookMatch = firstRef.match(/^([^\s]+)/); - if (bookMatch) { - return bookMatch[1]; - } - } - - // Priority 2: Fallback to cellMarkers (legacy support during migration) - if (cell?.cellMarkers?.[0]) { - const firstMarker = cell.cellMarkers[0].split(":")[0]; - if (firstMarker) { - const parts = firstMarker.split(" "); - return parts[0]; - } - } - - // Priority 3: Extract from cellId - const cellId = cell?.metadata?.id || cell?.id; - if (cellId) { - // Extract book name from cellId: "GEN 1:1" -> "GEN" - const bookMatch = cellId.match(/^([^\s]+)/); - if (bookMatch) { - return bookMatch[1]; - } - } - - return null; -} - -/** - * Gets the localized book name from a book abbreviation. - * Returns the abbreviation itself if no localized name is found. - */ -function getLocalizedBookName(bookAbbr: string): string { - if (!bookAbbr) return bookAbbr; - - const bookInfo = (bibleData as any[]).find((book) => book.abbr === bookAbbr); - return bookInfo?.name || bookAbbr; -} - /** * Extracts chapter number from a milestone value (e.g. "John 4", "4", "GEN 2"). * Used for verse-range migration to associate milestones with content chapters. @@ -2133,44 +2029,22 @@ function extractChapterNumberFromMilestoneValue(value: string | undefined): numb /** * Creates a milestone cell with book name and chapter number derived from the cell below it. - * Format: "BookName ChapterNumber" (e.g., "Isaiah 1") + * Format: "BookName ChapterNumber" (e.g., "Isaiah 1"). Thin wrapper around the + * shared `buildMilestoneCellPayload` helper that resolves the current user's + * author name through the auth API; the in-editor structural-edit handlers + * call the shared helper directly with `document._author`. * @param cell - The cell to derive chapter information from * @param milestoneIndex - The index of the milestone (1-indexed) * @param uuid - Optional UUID to use for the milestone cell. If not provided, generates a new one. */ async function createMilestoneCell(cell: any, milestoneIndex: number, uuid?: string): Promise { - const cellUuid = uuid || randomUUID(); - const chapterNumber = extractChapterFromCell(cell, milestoneIndex); - const currentTimestamp = Date.now(); const author = await getCurrentUserName(); - - // Extract book name from cell - const bookAbbr = extractBookNameFromCell(cell); - const bookName = bookAbbr ? getLocalizedBookName(bookAbbr) : null; - - // Combine book name and chapter number, or use just chapter number if no book name found - const milestoneValue = bookName ? `${bookName} ${chapterNumber}` : chapterNumber; - - // Create initial edit entry similar to source file cells - const initialEdit = { - editMap: EditMapUtils.value(), - value: milestoneValue, - timestamp: currentTimestamp - 1000, // Ensure it's before any user edits - type: EditType.INITIAL_IMPORT, - author: author, - validatedBy: [] - }; - - return { - kind: 2, // vscode.NotebookCellKind.Code - languageId: "html", - value: milestoneValue, - metadata: { - id: cellUuid, - type: CodexCellTypes.MILESTONE, - edits: [initialEdit] - } - }; + return buildMilestoneCellPayload({ + referenceCell: cell, + milestoneOrdinal: milestoneIndex, + author, + uuid, + }); } diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 08a824a76..b1318a8c8 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -21,6 +21,13 @@ import { toPosixPath } from "../../utils/pathUtils"; import { revalidateCellMissingFlags } from "../../utils/audioMissingUtils"; import { mergeAudioFiles } from "../../utils/audioMerger"; import { getAttachmentDocumentSegmentFromUri } from "../../utils/attachmentFolderUtils"; +import { isSourceFileFlexible } from "../../utils/fileTypeUtils"; +import { + FIRST_SUBDIVISION_KEY, + mergePlacementsForRemovedMilestone, + splitPlacementsAtAnchor, +} from "./utils/subdivisionUtils"; +import type { MilestoneSubdivisionPlacement } from "../../../types"; // Enable debug logging if needed const DEBUG_MODE = false; @@ -134,7 +141,12 @@ export async function sendMilestoneRefreshToWebview( if (currentPosition) { const config = vscode.workspace.getConfiguration("codex-editor-extension"); const cellsPerPage = config.get("cellsPerPage", 50); - const milestoneIndex = document.buildMilestoneIndex(cellsPerPage); + const maxSubdivisionLengthRaw = config.get("maxSubdivisionLength", 0); + const maxSubdivisionLength = + typeof maxSubdivisionLengthRaw === "number" && maxSubdivisionLengthRaw > 0 + ? Math.floor(maxSubdivisionLengthRaw) + : 0; + const milestoneIndex = document.buildMilestoneIndex(cellsPerPage, maxSubdivisionLength); const validationCount = vscode.workspace.getConfiguration("codex-project-manager").get("validationCount", 1); const validationCountAudio = vscode.workspace.getConfiguration("codex-project-manager").get("validationCountAudio", 1); @@ -142,7 +154,7 @@ export async function sendMilestoneRefreshToWebview( milestoneIndex.milestoneProgress = milestoneProgress; const isSourceText = document.uri.toString().includes(".source"); - const cells = document.getCellsForMilestone(currentPosition.milestoneIndex, currentPosition.subsectionIndex, cellsPerPage); + const cells = document.getCellsForMilestone(currentPosition.milestoneIndex, currentPosition.subsectionIndex, cellsPerPage, maxSubdivisionLength); const processedCells = provider.mergeRangesAndProcess(cells, provider.isCorrectionEditorMode, isSourceText); const sourceCellMap: { [k: string]: { content: string; versions: string[]; }; } = {}; @@ -158,6 +170,19 @@ export async function sendMilestoneRefreshToWebview( const username = userInfo?.username || "anonymous"; const rev = provider.getDocumentRevision(docUri); + const useSubdivisionNumberLabels = config.get( + "useSubdivisionNumberLabels", + false + ); + const enableMilestonePlacementEditing = config.get( + "enableMilestonePlacementEditing", + false + ); + // `force: true` marks these as server-initiated structural updates + // so the webview applies them even when its tracked position has + // shifted (e.g. after demote moves the cursor to milestone N-1). + // Without the flag the webview's stale guard rejects the message and + // leaves the accordion frozen on the pre-edit structure. safePostMessageToPanel(webviewPanel, { type: "providerSendsInitialContentPaginated", rev, @@ -170,6 +195,9 @@ export async function sendMilestoneRefreshToWebview( username: username, validationCount: validationCount, validationCountAudio: validationCountAudio, + useSubdivisionNumberLabels, + enableMilestonePlacementEditing, + force: true, }); safePostMessageToPanel(webviewPanel, { @@ -177,6 +205,7 @@ export async function sendMilestoneRefreshToWebview( rev, milestoneIndex: currentPosition.milestoneIndex, subsectionIndex: currentPosition.subsectionIndex, + force: true, }); debug(`[sendMilestoneRefreshToWebview] Sent updated milestone index and refreshCurrentPage for milestone ${currentPosition.milestoneIndex}, subsection ${currentPosition.subsectionIndex}`); } else { @@ -184,6 +213,1003 @@ export async function sendMilestoneRefreshToWebview( } } +/** + * Default label stamped on a milestone created from `addMilestoneAtCell` or + * `promoteSubdivisionToMilestone` when no other name is available (the + * boundary subdivision is unnamed and the caller didn't pass an override). + * + * We deliberately don't fall back to the chapter-style auto-label here — + * that would duplicate the parent milestone's name (e.g. two "Luke 1"s), + * which is the duplication users complain about. The placeholder makes it + * obvious the milestone needs a name. + */ +const NEW_MILESTONE_DEFAULT_LABEL = "New milestone"; + +/** + * Shared worker for all handlers that need to persist a new placement list + * onto a source milestone. Performs validation (source-only, milestone must + * exist, anchors must refer to real root cells), saves the source document, + * mirrors the placements (names stripped) onto the paired target document, + * and refreshes the originating webview. + * + * Callers pass `logPrefix` / `errorPrefix` so their diagnostic output stays + * attributable; the sanitization/mirror/refresh pipeline is identical across + * callers. + */ +async function commitMilestoneSubdivisionPlacements({ + document, + webviewPanel, + provider, + milestoneIndex, + incomingPlacements, + logPrefix, + errorPrefix, +}: { + document: CodexCellDocument; + webviewPanel: vscode.WebviewPanel; + provider: CodexCellEditorProvider; + milestoneIndex: number; + incomingPlacements: { startCellId: string; name?: string; }[]; + logPrefix: string; + errorPrefix: string; +}): Promise { + if (!isSourceFileFlexible(document.uri)) { + console.warn( + `${logPrefix} Rejected write from non-source document:`, + document.uri.toString() + ); + vscode.window.showWarningMessage( + "Subdivision breaks can only be edited from the source file." + ); + return; + } + + const milestoneIndexObj = document.buildMilestoneIndex(); + const milestone = milestoneIndexObj.milestones[milestoneIndex]; + if (!milestone) { + console.error(`${logPrefix} Milestone not found at index`, milestoneIndex); + vscode.window.showErrorMessage( + `${errorPrefix}: milestone not found at index ${milestoneIndex}` + ); + return; + } + + const sourceMilestoneCell = document.getCellByIndex(milestone.cellIndex); + if ( + !sourceMilestoneCell || + sourceMilestoneCell.metadata?.type !== CodexCellTypes.MILESTONE || + !sourceMilestoneCell.metadata?.id + ) { + console.error( + `${logPrefix} Cell at index is not a valid milestone cell`, + milestone.cellIndex + ); + vscode.window.showErrorMessage(`${errorPrefix}: invalid milestone cell.`); + return; + } + + // Validate every anchor refers to a current root content cell inside the + // milestone. Stale anchors are normally pruned at resolve time, but we + // still hard-reject invalid writes here so bugs surface as loud errors + // rather than silent data loss. + const validRootIds = new Set(document.getRootContentCellIdsForMilestone(milestoneIndex)); + const seen = new Set(); + const sanitized: { startCellId: string; name?: string; }[] = []; + for (const placement of incomingPlacements) { + if (!placement || typeof placement.startCellId !== "string") continue; + if (!validRootIds.has(placement.startCellId)) { + console.warn(`${logPrefix} Dropping unknown startCellId:`, placement.startCellId); + continue; + } + if (seen.has(placement.startCellId)) continue; + seen.add(placement.startCellId); + const entry: { startCellId: string; name?: string; } = { + startCellId: placement.startCellId, + }; + if (typeof placement.name === "string" && placement.name.length > 0) { + entry.name = placement.name; + } + sanitized.push(entry); + } + + const sourceCellId = sourceMilestoneCell.metadata.id; + const cancellationToken = new vscode.CancellationTokenSource().token; + + // In-memory source mutation only; persistence is hoisted to the bottom + // so the webview refresh fires before disk I/O completes — keeping the + // subdivision break edit visually instant. See the matching block in + // `commitSplitMilestoneAtAnchor` for the rationale. + try { + await document.refreshAuthor(); + document.updateCellData(sourceCellId, { subdivisions: sanitized }); + } catch (error) { + console.error(`${logPrefix} Failed to update source:`, error); + vscode.window.showErrorMessage( + `${errorPrefix}: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + + // Mirror placements (with names) and the source's full `subdivisionNames` + // map onto the paired target. Names ride along on the placement objects so + // a user-set source name shows on the target by default; the source-name + // map is mirrored separately into `subdivisionNamesFromSource` so the + // target can fall back on it for the implicit first subdivision and for + // entries renamed via the rename pencil (which writes to + // `subdivisionNames`, not into placement.name). + // + // Tracks the target document we successfully mirrored to so we can refresh + // its webview after the source-side refresh below. Only set when the + // mirror actually wrote new data; left null when the target is unpaired, + // out of sync, or the mirror failed. + let mirroredTargetDocument: CodexCellDocument | null = null; + try { + const pairedUri = provider.getPairedNotebookUri(document.uri); + if (pairedUri) { + const targetDocument = await provider.getOrOpenDocumentForUri(pairedUri); + const targetMilestoneIndexObj = targetDocument.buildMilestoneIndex(); + const targetMilestone = targetMilestoneIndexObj.milestones[milestoneIndex]; + if (!targetMilestone) { + console.warn( + `${logPrefix} Target has no milestone at index, skipping mirror:`, + milestoneIndex + ); + } else { + // Positionally-paired milestones should share root content + // cell IDs. If they diverge we skip the mirror so source + // anchors don't latch onto unrelated target cells. + const sourceRootIds = document.getRootContentCellIdsForMilestone(milestoneIndex); + const targetRootIds = targetDocument.getRootContentCellIdsForMilestone(milestoneIndex); + const rootsMatch = + sourceRootIds.length === targetRootIds.length && + sourceRootIds.every((id, i) => id === targetRootIds[i]); + if (!rootsMatch) { + console.warn(`${logPrefix} Source/target milestones diverge; skipping mirror.`, { + milestoneIndex, + sourceLength: sourceRootIds.length, + targetLength: targetRootIds.length, + }); + } else { + const targetMilestoneCell = targetDocument.getCellByIndex( + targetMilestone.cellIndex + ); + if (targetMilestoneCell?.metadata?.id) { + // Preserve `name` so a target translator sees source + // labels by default until they override locally. + const mirroredPlacements = sanitized.map((p) => { + const out: { startCellId: string; name?: string; } = { + startCellId: p.startCellId, + }; + if (typeof p.name === "string" && p.name.length > 0) { + out.name = p.name; + } + return out; + }); + + // Snapshot the source's localized subdivision names so + // the target can present them as defaults. Only string + // values are forwarded. + const sourceData = sourceMilestoneCell.metadata?.data as + | { subdivisionNames?: { [k: string]: string; }; } + | undefined; + const mirroredSourceNames: { [k: string]: string; } = {}; + if (sourceData?.subdivisionNames) { + for (const [k, v] of Object.entries(sourceData.subdivisionNames)) { + if (typeof v === "string" && v.length > 0) { + mirroredSourceNames[k] = v; + } + } + } + + await targetDocument.refreshAuthor(); + targetDocument.updateCellData(targetMilestoneCell.metadata.id, { + subdivisions: mirroredPlacements, + subdivisionNamesFromSource: mirroredSourceNames, + }); + mirroredTargetDocument = targetDocument; + } + } + } + } + } catch (mirrorError) { + // Mirror failures are non-fatal: the source edit has already + // succeeded. Log and continue so the user can retry later. + console.error(`${logPrefix} Failed to mirror to target:`, mirrorError); + } + + await sendMilestoneRefreshToWebview(document, webviewPanel, provider); + + // Push a refresh to the paired target webview if it's currently open. + // Cost is one extra postMessage + a milestone-index rebuild on the target + // (already cached and invalidated by `updateCellData`), so the marginal + // overhead per source-side break edit is negligible. When no target + // webview is open we skip silently — it'll pick up the change on next + // load via the persisted file. + if (mirroredTargetDocument) { + const targetPanel = provider.getWebviewPanelForUri(mirroredTargetDocument.uri); + if (targetPanel) { + try { + await sendMilestoneRefreshToWebview( + mirroredTargetDocument, + targetPanel, + provider + ); + } catch (refreshError) { + console.error( + `${logPrefix} Failed to refresh paired target webview:`, + refreshError + ); + } + } + } + + // Persist source + target in parallel AFTER the webviews have been told + // about the new placements. Mirrors the snappy ordering used by the + // structural milestone helpers — disk I/O latency no longer gates the + // user's perceived completion of subdivision break edits. + const persistenceWork: Promise[] = [ + provider.saveCustomDocument(document, cancellationToken), + ]; + if (mirroredTargetDocument) { + persistenceWork.push( + provider.saveCustomDocument(mirroredTargetDocument, cancellationToken) + ); + } + try { + await Promise.all(persistenceWork); + } catch (persistError) { + console.error(`${logPrefix} Persistence failed (UI already updated):`, persistError); + vscode.window.showErrorMessage( + `${errorPrefix}: failed to persist changes — ${ + persistError instanceof Error ? persistError.message : String(persistError) + }` + ); + } +} + +/** + * Reads the existing placements & subdivisionNames blob off a milestone cell. + * Tolerant of missing fields so callers can use it on freshly-created + * milestones (returns empty maps/arrays). + */ +function readMilestoneSubdivisionData(milestoneCell: any): { + placements: MilestoneSubdivisionPlacement[]; + subdivisionNames: { [k: string]: string; }; + subdivisionNamesFromSource: { [k: string]: string; }; +} { + const data = milestoneCell?.metadata?.data as + | { + subdivisions?: MilestoneSubdivisionPlacement[]; + subdivisionNames?: { [k: string]: string; }; + subdivisionNamesFromSource?: { [k: string]: string; }; + } + | undefined; + return { + placements: Array.isArray(data?.subdivisions) ? [...(data!.subdivisions!)] : [], + subdivisionNames: { ...(data?.subdivisionNames ?? {}) }, + subdivisionNamesFromSource: { ...(data?.subdivisionNamesFromSource ?? {}) }, + }; +} + +/** + * Partitions a `subdivisionNames` map across a milestone split. + * + * `keepKeys` is the set of keys that remain on the original (now-shorter) + * milestone — typically `FIRST_SUBDIVISION_KEY` plus the `startCellId` of + * every placement still in the "before" partition. `moveKeys` is the set of + * keys whose entries should travel to the new milestone, optionally remapped + * (e.g. the boundary anchor key becomes `FIRST_SUBDIVISION_KEY` on the new + * milestone since it's that milestone's implicit first subdivision now). + */ +function partitionSubdivisionNames( + nameMap: { [k: string]: string; }, + keepKeys: Set, + moveKeys: Map +): { kept: { [k: string]: string; }; moved: { [k: string]: string; }; } { + const kept: { [k: string]: string; } = {}; + const moved: { [k: string]: string; } = {}; + for (const [key, value] of Object.entries(nameMap)) { + if (typeof value !== "string" || value.length === 0) continue; + if (keepKeys.has(key)) { + kept[key] = value; + } else if (moveKeys.has(key)) { + const remappedKey = moveKeys.get(key)!; + moved[remappedKey] = value; + } + // Keys outside both sets are dropped (they reference cells that no + // longer correspond to any placement on either milestone — typically + // stale entries that the resolver was already pruning at render time). + } + return { kept, moved }; +} + +/** + * When a milestone is removed (merge / demote), merges the surviving and + * removed milestones' `subdivisionNames` maps onto the survivor the same way + * as the source-side `mergedSourceNames` block: non-`__start__` keys keep + * their cell-id keys; the removed milestone's implicit start re-keys onto the + * seam anchor when `preserveBoundary`, else dropped. + */ +function mergeSubdivisionNameMapsForSurvivorAfterRemovedMilestone( + previousNames: { [k: string]: string }, + removedNames: { [k: string]: string }, + boundaryAnchorCellId: string | undefined, + preserveBoundary: boolean +): { [k: string]: string } { + const merged: { [k: string]: string } = { ...previousNames }; + for (const [key, value] of Object.entries(removedNames)) { + if (typeof value !== "string" || value.length === 0) continue; + if (key === FIRST_SUBDIVISION_KEY) { + if (preserveBoundary && boundaryAnchorCellId) { + merged[boundaryAnchorCellId] = value; + } + continue; + } + merged[key] = value; + } + return merged; +} + +/** + * Validates that the source and target documents agree on which root content + * cells belong to a given milestone index. Used as a pre-flight before any + * structural milestone edit so we never insert / soft-delete a target cell + * by UUID when the documents have already drifted apart structurally. + */ +function sourceAndTargetMilestoneRootsMatch( + sourceDocument: CodexCellDocument, + targetDocument: CodexCellDocument, + milestoneIndex: number +): boolean { + const sourceRootIds = sourceDocument.getRootContentCellIdsForMilestone(milestoneIndex); + const targetRootIds = targetDocument.getRootContentCellIdsForMilestone(milestoneIndex); + if (sourceRootIds.length !== targetRootIds.length) return false; + for (let i = 0; i < sourceRootIds.length; i++) { + if (sourceRootIds[i] !== targetRootIds[i]) return false; + } + return true; +} + +/** + * Handles the source-side soft-delete + previous-milestone redistribution + * shared by `removeMilestone` and `demoteMilestoneToSubdivision`. The two + * commands differ only in `preserveBoundary`: demote keeps a custom + * subdivision break at the seam (carrying the deleted milestone's label as + * its name), remove drops the boundary entirely. + * + * Always mirrors to the paired target by UUID. When the target's root cell + * IDs for the affected milestones diverge from source we skip the structural + * mirror so we never delete a target milestone whose content has drifted — + * the callers surface a console.warn so the divergence is visible without + * popping a dialog at every save. + */ +async function commitMergeMilestoneIntoPrevious({ + document, + webviewPanel, + provider, + milestoneIndex, + preserveBoundary, + logPrefix, + errorPrefix, +}: { + document: CodexCellDocument; + webviewPanel: vscode.WebviewPanel; + provider: CodexCellEditorProvider; + milestoneIndex: number; + preserveBoundary: boolean; + logPrefix: string; + errorPrefix: string; +}): Promise { + if (!isSourceFileFlexible(document.uri)) { + console.warn( + `${logPrefix} Rejected write from non-source document:`, + document.uri.toString() + ); + vscode.window.showWarningMessage( + "Milestone placements can only be edited from the source file." + ); + return; + } + + if (milestoneIndex <= 0) { + console.warn( + `${logPrefix} Cannot remove the first milestone (or virtual milestone) at index 0` + ); + vscode.window.showWarningMessage( + "The first milestone cannot be removed." + ); + return; + } + + const sourceMilestoneIndex = document.buildMilestoneIndex(); + const removed = sourceMilestoneIndex.milestones[milestoneIndex]; + const previous = sourceMilestoneIndex.milestones[milestoneIndex - 1]; + if (!removed || !previous) { + console.error(`${logPrefix} Milestone neighbours not found`, { + milestoneIndex, + }); + vscode.window.showErrorMessage( + `${errorPrefix}: milestone neighbours not found at index ${milestoneIndex}` + ); + return; + } + + const removedCell = document.getCellByIndex(removed.cellIndex); + const previousCell = document.getCellByIndex(previous.cellIndex); + if ( + !removedCell || + removedCell.metadata?.type !== CodexCellTypes.MILESTONE || + !removedCell.metadata?.id || + !previousCell || + previousCell.metadata?.type !== CodexCellTypes.MILESTONE || + !previousCell.metadata?.id + ) { + console.error(`${logPrefix} Invalid milestone cells`, { + removed: removed.cellIndex, + previous: previous.cellIndex, + }); + vscode.window.showErrorMessage(`${errorPrefix}: invalid milestone cells.`); + return; + } + + const removedMilestoneCellId = removedCell.metadata.id; + const removedRootIds = document.getRootContentCellIdsForMilestone(milestoneIndex); + // Capture both milestones' root id lists BEFORE we mutate the source so + // we can compare them against the (still pre-mutation) target inside + // the mirror block. The merge expands the previous milestone's root + // set to include the removed one's roots, so reading the source's + // roots POST-mutation would never match the still-unmutated target + // and the structural mirror would silently skip. + const previousRootIdsBefore = + document.getRootContentCellIdsForMilestone(milestoneIndex - 1); + const boundaryAnchorCellId = removedRootIds[0]; + const removedLabel = (removedCell.value as string | undefined) ?? ""; + const removedData = readMilestoneSubdivisionData(removedCell); + const previousData = readMilestoneSubdivisionData(previousCell); + + const merged = mergePlacementsForRemovedMilestone({ + prevPlacements: previousData.placements, + removedPlacements: removedData.placements, + boundaryAnchorCellId, + boundaryName: removedLabel.length > 0 ? removedLabel : undefined, + preserveBoundary, + }); + + // Combine subdivisionNames maps. Removed milestone's __start__ entry maps + // onto the boundary cell ID on the surviving milestone (so its label + // travels with the cells). All other named entries keep their cell-ID + // keys verbatim — they still resolve to the same root cells, just inside + // a wider milestone range now. + const mergedSourceNames = mergeSubdivisionNameMapsForSurvivorAfterRemovedMilestone( + previousData.subdivisionNames, + removedData.subdivisionNames, + boundaryAnchorCellId, + preserveBoundary + ); + + const cancellationToken = new vscode.CancellationTokenSource().token; + + // In-memory source mutation only. Persistence + FTS reflush happen at + // the end so the source webview sees the merge before disk I/O finishes. + try { + await document.refreshAuthor(); + document.softDeleteCell(removedMilestoneCellId); + document.updateCellData(previousCell.metadata.id, { + subdivisions: merged.placements, + subdivisionNames: mergedSourceNames, + }); + } catch (error) { + console.error(`${logPrefix} Failed to update source:`, error); + vscode.window.showErrorMessage( + `${errorPrefix}: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + + let mirroredTargetDocument: CodexCellDocument | null = null; + try { + const pairedUri = provider.getPairedNotebookUri(document.uri); + if (pairedUri) { + const targetDocument = await provider.getOrOpenDocumentForUri(pairedUri); + const targetMilestones = targetDocument.buildMilestoneIndex(); + const targetRemoved = targetMilestones.milestones[milestoneIndex]; + const targetPrevious = targetMilestones.milestones[milestoneIndex - 1]; + + // Skip mirror unless the target has the same milestone neighbours + // backed by cells with matching IDs. We compare BOTH milestones' + // root cell ID lists so a previously-divergent pair (e.g. user + // mutated the target file independently) doesn't end up with a + // missing milestone on one side after the merge. We use the + // source's PRE-mutation root ids captured above; reading the + // source's roots NOW would include the merge expansion and + // never match the still-unmutated target. + const removedCellId = targetRemoved + ? targetDocument.getCellByIndex(targetRemoved.cellIndex)?.metadata?.id + : undefined; + const removedIdMatches = removedCellId === removedMilestoneCellId; + const targetPreviousRootIds = + targetDocument.getRootContentCellIdsForMilestone(milestoneIndex - 1); + const targetRemovedRootIds = + targetDocument.getRootContentCellIdsForMilestone(milestoneIndex); + const rootsMatchPrev = + previousRootIdsBefore.length === targetPreviousRootIds.length && + previousRootIdsBefore.every((id, i) => id === targetPreviousRootIds[i]); + const rootsMatchRemoved = + removedRootIds.length === targetRemovedRootIds.length && + removedRootIds.every((id, i) => id === targetRemovedRootIds[i]); + + if (!targetRemoved || !targetPrevious || !removedIdMatches || !rootsMatchPrev || !rootsMatchRemoved) { + console.warn(`${logPrefix} Source/target diverge; skipping structural mirror.`, { + milestoneIndex, + removedIdMatches, + rootsMatchPrev, + rootsMatchRemoved, + }); + } else { + const targetPreviousCell = targetDocument.getCellByIndex( + targetPrevious.cellIndex + ); + const targetRemovedCell = targetDocument.getCellByIndex( + targetRemoved.cellIndex + ); + if (targetPreviousCell?.metadata?.id && targetRemovedCell) { + const targetPreviousData = readMilestoneSubdivisionData(targetPreviousCell); + const targetRemovedData = readMilestoneSubdivisionData(targetRemovedCell); + const mergedTargetLocalNames = + mergeSubdivisionNameMapsForSurvivorAfterRemovedMilestone( + targetPreviousData.subdivisionNames, + targetRemovedData.subdivisionNames, + boundaryAnchorCellId, + preserveBoundary + ); + await targetDocument.refreshAuthor(); + targetDocument.softDeleteCell(removedMilestoneCellId); + // Mirror source defaults into `subdivisionNamesFromSource`; + // fold the translator's own `subdivisionNames` from both + // milestones so names they set on the removed side travel + // onto the survivor (same semantics as the source merge). + targetDocument.updateCellData(targetPreviousCell.metadata.id, { + subdivisions: merged.placements, + subdivisionNames: mergedTargetLocalNames, + subdivisionNamesFromSource: mergedSourceNames, + }); + mirroredTargetDocument = targetDocument; + } + } + } + } catch (mirrorError) { + console.error(`${logPrefix} Failed to mirror to target:`, mirrorError); + } + + // Adjust the cached milestone position so the user lands on the survivor + // rather than a phantom index after the merge. If they were on the + // removed milestone, we shift to the previous one; if they were below + // it, we shift down by 1 to keep the same content in view. + const docUri = document.uri.toString(); + const cachedPosition = provider.currentMilestoneSubsectionMap.get(docUri); + if (cachedPosition && cachedPosition.milestoneIndex >= milestoneIndex) { + provider.currentMilestoneSubsectionMap.set(docUri, { + milestoneIndex: Math.max(0, cachedPosition.milestoneIndex - 1), + subsectionIndex: 0, + }); + } + + await sendMilestoneRefreshToWebview(document, webviewPanel, provider); + + if (mirroredTargetDocument) { + const targetUri = mirroredTargetDocument.uri.toString(); + const targetCachedPosition = + provider.currentMilestoneSubsectionMap.get(targetUri); + if ( + targetCachedPosition && + targetCachedPosition.milestoneIndex >= milestoneIndex + ) { + provider.currentMilestoneSubsectionMap.set(targetUri, { + milestoneIndex: Math.max(0, targetCachedPosition.milestoneIndex - 1), + subsectionIndex: 0, + }); + } + const targetPanel = provider.getWebviewPanelForUri(mirroredTargetDocument.uri); + if (targetPanel) { + try { + await sendMilestoneRefreshToWebview( + mirroredTargetDocument, + targetPanel, + provider + ); + } catch (refreshError) { + console.error( + `${logPrefix} Failed to refresh paired target webview:`, + refreshError + ); + } + } + } + + // Persist source + target + FTS rebuilds in parallel AFTER the webviews + // have already been told about the merge. See the matching block in + // `commitSplitMilestoneAtAnchor` for the rationale. + const persistenceWork: Promise[] = [ + provider.saveCustomDocument(document, cancellationToken), + document.updateCellMilestoneIndices({ force: true }), + ]; + if (mirroredTargetDocument) { + persistenceWork.push( + provider.saveCustomDocument(mirroredTargetDocument, cancellationToken), + mirroredTargetDocument.updateCellMilestoneIndices({ force: true }), + ); + } + try { + await Promise.all(persistenceWork); + } catch (persistError) { + console.error(`${logPrefix} Persistence failed (UI already updated):`, persistError); + vscode.window.showErrorMessage( + `${errorPrefix}: failed to persist changes — ${ + persistError instanceof Error ? persistError.message : String(persistError) + }` + ); + } +} + +/** + * Inserts a new milestone cell at the given anchor inside an existing + * milestone, partitioning the existing placements + subdivisionNames across + * the new boundary, and mirroring the structural change to the paired target + * document by UUID. + * + * Used by both `addMilestoneAtCell` (anchor = N-th root cell, no pre-existing + * placement) and `promoteSubdivisionToMilestone` (anchor = an existing + * custom subdivision break). The two callers differ only in how they choose + * the anchor and the new milestone's label. + */ +async function commitSplitMilestoneAtAnchor({ + document, + webviewPanel, + provider, + milestoneIndex, + boundaryCellId, + newMilestoneLabel, + logPrefix, + errorPrefix, +}: { + document: CodexCellDocument; + webviewPanel: vscode.WebviewPanel; + provider: CodexCellEditorProvider; + milestoneIndex: number; + boundaryCellId: string; + /** + * Optional label for the new milestone cell. When provided we override + * the auto-derived `"BookName ChapterNumber"` default — typical when + * promoting a named subdivision: the user already has a label they + * meant for this section. + */ + newMilestoneLabel?: string; + logPrefix: string; + errorPrefix: string; +}): Promise { + if (!isSourceFileFlexible(document.uri)) { + console.warn( + `${logPrefix} Rejected write from non-source document:`, + document.uri.toString() + ); + vscode.window.showWarningMessage( + "Milestone placements can only be edited from the source file." + ); + return; + } + + const sourceMilestoneIndex = document.buildMilestoneIndex(); + const original = sourceMilestoneIndex.milestones[milestoneIndex]; + if (!original) { + console.error(`${logPrefix} Milestone not found at index`, milestoneIndex); + vscode.window.showErrorMessage( + `${errorPrefix}: milestone not found at index ${milestoneIndex}` + ); + return; + } + + const originalCell = document.getCellByIndex(original.cellIndex); + if ( + !originalCell || + originalCell.metadata?.type !== CodexCellTypes.MILESTONE || + !originalCell.metadata?.id + ) { + console.error(`${logPrefix} Invalid milestone cell`, original.cellIndex); + vscode.window.showErrorMessage(`${errorPrefix}: invalid milestone cell.`); + return; + } + + const rootIds = document.getRootContentCellIdsForMilestone(milestoneIndex); + const boundaryRootIndex = rootIds.indexOf(boundaryCellId); + if (boundaryRootIndex <= 0) { + // Boundary at index 0 would create an empty milestone before the + // anchor; -1 means the cell isn't even in this milestone (stale UI + // state). + console.warn(`${logPrefix} Boundary out of range`, { + boundaryCellId, + boundaryRootIndex, + rootCount: rootIds.length, + }); + vscode.window.showWarningMessage( + `${errorPrefix}: cannot split at this cell.` + ); + return; + } + + const originalData = readMilestoneSubdivisionData(originalCell); + const split = splitPlacementsAtAnchor( + originalData.placements, + rootIds, + boundaryCellId + ); + + // Partition subdivisionNames at the boundary so labels travel with their + // section. The implicit first subdivision (FIRST_SUBDIVISION_KEY) stays + // on the original milestone. The boundary cell's name (if any) becomes + // the new milestone's __start__ subdivision name. All other entries are + // sorted by whether their cell ID falls before or after the anchor. + const keepKeys = new Set([FIRST_SUBDIVISION_KEY]); + const moveKeys = new Map(); + for (let i = 0; i < boundaryRootIndex; i++) { + keepKeys.add(rootIds[i]); + } + moveKeys.set(boundaryCellId, FIRST_SUBDIVISION_KEY); + for (let i = boundaryRootIndex + 1; i < rootIds.length; i++) { + moveKeys.set(rootIds[i], rootIds[i]); + } + const sourceNamePartition = partitionSubdivisionNames( + originalData.subdivisionNames, + keepKeys, + moveKeys + ); + + // The label takes precedence on the milestone row. Cascade: + // 1. Explicit override from the caller (promote-named-subdivision). + // 2. Boundary placement's inline name. + // 3. Subdivision-names override at the boundary key. + // 4. Generic "New milestone" placeholder. + // We deliberately do NOT fall back to the chapter-style default here: + // that would clone the parent milestone's label (e.g. two "Luke 1"s), + // which is exactly the duplication the user has to rename away from. + const fallbackBoundaryName = + split.boundaryName ?? sourceNamePartition.moved[FIRST_SUBDIVISION_KEY]; + const valueOverride = + newMilestoneLabel || fallbackBoundaryName || NEW_MILESTONE_DEFAULT_LABEL; + + // Newly-created milestone gets its own subdivisions/subdivisionNames + // populated atomically in the same `insertMilestoneCell` call, sparing + // us a follow-up updateCellData round-trip. + const newMilestoneInitialData: Record = {}; + if (split.after.length > 0) { + newMilestoneInitialData.subdivisions = split.after; + } + if (Object.keys(sourceNamePartition.moved).length > 0) { + newMilestoneInitialData.subdivisionNames = sourceNamePartition.moved; + } + + const cancellationToken = new vscode.CancellationTokenSource().token; + let insertedMilestoneCellId: string; + + // In-memory source mutation only; persistence (saveCustomDocument + + // updateCellMilestoneIndices) is hoisted to the bottom of the function so + // the webview refresh fires before disk I/O completes — keeping + // promote/demote/add/remove visually instant. + try { + await document.refreshAuthor(); + const inserted = document.insertMilestoneCell({ + referenceCellId: boundaryCellId, + valueOverride, + initialData: newMilestoneInitialData, + }); + insertedMilestoneCellId = inserted.cellId; + document.updateCellData(originalCell.metadata.id, { + subdivisions: split.before, + subdivisionNames: sourceNamePartition.kept, + }); + } catch (error) { + console.error(`${logPrefix} Failed to update source:`, error); + vscode.window.showErrorMessage( + `${errorPrefix}: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + + let mirroredTargetDocument: CodexCellDocument | null = null; + try { + const pairedUri = provider.getPairedNotebookUri(document.uri); + if (pairedUri) { + const targetDocument = await provider.getOrOpenDocumentForUri(pairedUri); + // Match the source's pre-split milestoneIndex on the target. We + // rebuild the target's milestone index here because the source + // document's index has already been mutated; we can't use the + // source's `original` info anymore. + const targetMilestones = targetDocument.buildMilestoneIndex(); + // After the source insert, source's milestone count = target's + 1. + // We mirror against the SAME starting milestone index on the + // target (it hasn't been mutated yet), which corresponds to the + // same `milestoneIndex` parameter on this side too. + const targetOriginal = targetMilestones.milestones[milestoneIndex]; + if (!targetOriginal) { + console.warn(`${logPrefix} Target has no milestone at index, skipping mirror.`, { + milestoneIndex, + }); + } else { + const targetOriginalCell = targetDocument.getCellByIndex( + targetOriginal.cellIndex + ); + const sharedOriginalId = + targetOriginalCell?.metadata?.id === originalCell.metadata.id; + // The source's pre-split root IDs (captured in `rootIds` + // before we mutated) must equal the target's CURRENT root + // IDs. We can't reuse `sourceAndTargetMilestoneRootsMatch` + // here because the source has already been mutated, so its + // milestone[milestoneIndex] roots are now just the BEFORE + // partition. + const targetRootIds = targetDocument.getRootContentCellIdsForMilestone( + milestoneIndex + ); + let rootsMatch = targetRootIds.length === rootIds.length; + if (rootsMatch) { + for (let i = 0; i < rootIds.length; i++) { + if (rootIds[i] !== targetRootIds[i]) { + rootsMatch = false; + break; + } + } + } + + if (!sharedOriginalId || !rootsMatch) { + console.warn(`${logPrefix} Source/target diverge; skipping structural mirror.`, { + milestoneIndex, + sharedOriginalId, + rootsMatch, + }); + } else if (targetOriginalCell?.metadata?.id) { + const targetData = readMilestoneSubdivisionData(targetOriginalCell); + // Target-only label at the promoted seam: prefer it for the + // new milestone's cell.value so a translator-named break is + // not replaced by the source-only placeholder (e.g. "New + // milestone") when the source had no name at this anchor. + const rawTargetBoundaryLocal = + targetData.subdivisionNames[boundaryCellId]; + const targetLocalBoundaryLabel = + typeof rawTargetBoundaryLocal === "string" && + rawTargetBoundaryLocal.trim().length > 0 + ? rawTargetBoundaryLocal.trim() + : undefined; + const targetValueOverride = + targetLocalBoundaryLabel ?? valueOverride; + + const targetNamePartition = partitionSubdivisionNames( + targetData.subdivisionNames, + keepKeys, + moveKeys + ); + const targetSourceNamePartition = partitionSubdivisionNames( + targetData.subdivisionNamesFromSource, + keepKeys, + moveKeys + ); + + const targetInitialData: Record = {}; + if (split.after.length > 0) { + targetInitialData.subdivisions = split.after; + } + if (Object.keys(targetNamePartition.moved).length > 0) { + targetInitialData.subdivisionNames = targetNamePartition.moved; + } + // Mirror the SOURCE's name partition into the target's + // `subdivisionNamesFromSource` so the target inherits + // source-side labels for the new milestone by default. + const mergedSourceNamesForNewMilestone = { + ...targetSourceNamePartition.moved, + ...sourceNamePartition.moved, + }; + if (Object.keys(mergedSourceNamesForNewMilestone).length > 0) { + targetInitialData.subdivisionNamesFromSource = + mergedSourceNamesForNewMilestone; + } + + await targetDocument.refreshAuthor(); + targetDocument.insertMilestoneCell({ + newCellId: insertedMilestoneCellId, + referenceCellId: boundaryCellId, + valueOverride: targetValueOverride, + initialData: targetInitialData, + }); + // Update the original target milestone with the BEFORE + // partition of placements + names. Mirror the source's + // BEFORE-partition names map into the target's + // subdivisionNamesFromSource so cross-side rendering + // stays consistent. + const mergedSourceNamesForOriginal = { + ...targetSourceNamePartition.kept, + ...sourceNamePartition.kept, + }; + targetDocument.updateCellData(targetOriginalCell.metadata.id, { + subdivisions: split.before, + subdivisionNames: targetNamePartition.kept, + subdivisionNamesFromSource: mergedSourceNamesForOriginal, + }); + mirroredTargetDocument = targetDocument; + } + } + } + } catch (mirrorError) { + console.error(`${logPrefix} Failed to mirror to target:`, mirrorError); + } + + // Adjust cached position so a user viewing milestone N+ stays on the + // equivalent content after the split. Viewers on the original milestone + // (index === milestoneIndex) stay put — the original is now the first + // half, which still makes sense as their current position. + const docUri = document.uri.toString(); + const cachedPosition = provider.currentMilestoneSubsectionMap.get(docUri); + if (cachedPosition && cachedPosition.milestoneIndex > milestoneIndex) { + provider.currentMilestoneSubsectionMap.set(docUri, { + milestoneIndex: cachedPosition.milestoneIndex + 1, + subsectionIndex: cachedPosition.subsectionIndex, + }); + } + + await sendMilestoneRefreshToWebview(document, webviewPanel, provider); + + if (mirroredTargetDocument) { + const targetUri = mirroredTargetDocument.uri.toString(); + const targetCachedPosition = + provider.currentMilestoneSubsectionMap.get(targetUri); + if ( + targetCachedPosition && + targetCachedPosition.milestoneIndex > milestoneIndex + ) { + provider.currentMilestoneSubsectionMap.set(targetUri, { + milestoneIndex: targetCachedPosition.milestoneIndex + 1, + subsectionIndex: targetCachedPosition.subsectionIndex, + }); + } + const targetPanel = provider.getWebviewPanelForUri(mirroredTargetDocument.uri); + if (targetPanel) { + try { + await sendMilestoneRefreshToWebview( + mirroredTargetDocument, + targetPanel, + provider + ); + } catch (refreshError) { + console.error( + `${logPrefix} Failed to refresh paired target webview:`, + refreshError + ); + } + } + } + + // Persist source + target + FTS rebuilds in parallel AFTER the webviews + // have already been told about the new structure. The in-memory state + // is the source of truth for the refresh, so disk I/O latency no longer + // gates the user's perceived completion of promote/demote/add/remove. + const persistenceWork: Promise[] = [ + provider.saveCustomDocument(document, cancellationToken), + document.updateCellMilestoneIndices({ force: true }), + ]; + if (mirroredTargetDocument) { + persistenceWork.push( + provider.saveCustomDocument(mirroredTargetDocument, cancellationToken), + mirroredTargetDocument.updateCellMilestoneIndices({ force: true }), + ); + } + try { + await Promise.all(persistenceWork); + } catch (persistError) { + console.error(`${logPrefix} Persistence failed (UI already updated):`, persistError); + vscode.window.showErrorMessage( + `${errorPrefix}: failed to persist changes — ${ + persistError instanceof Error ? persistError.message : String(persistError) + }` + ); + } +} + /** * Helper function to get the audio file path for a cell * Checks metadata attachments first, then falls back to filesystem lookup @@ -315,7 +1341,8 @@ async function getAudioFilePathForCell( return null; } -// Individual message handlers +// Individual message handlers. Exported (as a re-export below) so tests can +// invoke handlers directly without constructing a full webview round-trip. const messageHandlers: Record Promise | void> = { webviewReady: () => { /* no-op */ }, setAutoDownloadAudioOnOpen: async ({ event, document, webviewPanel, provider }) => { @@ -1246,6 +2273,8 @@ const messageHandlers: Record Promise Promise[] = [ + provider.saveCustomDocument(document, cancellationToken), + ]; + if (mirroredTargetDocument) { + persistenceWork.push( + provider.saveCustomDocument(mirroredTargetDocument, cancellationToken) + ); + } + try { + await Promise.all(persistenceWork); + debug( + `[updateMilestoneValue] Saved rename for milestone ${typedEvent.content.milestoneIndex} (mirrored=${mirroredTargetDocument ? "yes" : "no"})` + ); + } catch (saveError) { + console.error( + `[updateMilestoneValue] Failed to save file ${document.uri.fsPath}:`, + saveError + ); + vscode.window.showErrorMessage( + `Failed to save milestone update: ${ + saveError instanceof Error ? saveError.message : String(saveError) + }` + ); + } + }, + + refreshWebviewAfterMilestoneEdits: async ({ document, webviewPanel, provider }) => { + await sendMilestoneRefreshToWebview(document, webviewPanel, provider); + }, + + updateMilestoneSubdivisions: async ({ event, document, webviewPanel, provider }) => { + const typedEvent = event as Extract; + debug("updateMilestoneSubdivisions message received", { event }); + await commitMilestoneSubdivisionPlacements({ + document, + webviewPanel, + provider, + milestoneIndex: typedEvent.content.milestoneIndex, + incomingPlacements: typedEvent.content.subdivisions ?? [], + logPrefix: "[updateMilestoneSubdivisions]", + errorPrefix: "Failed to update subdivisions", + }); + }, + + addMilestoneSubdivisionAnchor: async ({ event, document, webviewPanel, provider }) => { + const typedEvent = event as Extract< + EditorPostMessages, + { command: "addMilestoneSubdivisionAnchor"; } + >; + debug("addMilestoneSubdivisionAnchor message received", { event }); + + // Placements are source-authoritative. Reject writes from target so the + // source stays the single source of truth; the UI hides the control too. + if (!isSourceFileFlexible(document.uri)) { + console.warn( + "[addMilestoneSubdivisionAnchor] Rejected write from non-source document:", + document.uri.toString() + ); + vscode.window.showWarningMessage( + "Subdivision breaks can only be added from the source file." + ); + return; + } + + const { milestoneIndex, cellNumber } = typedEvent.content; + + // Resolve `cellNumber` (1-based) to a concrete root cell ID. The valid + // range is [2, rootIds.length]: splitting at cell 1 would duplicate the + // implicit first subdivision, and splitting beyond the last cell has + // nowhere to go. We reject both cases rather than silently clamping so + // the caller can surface a clear error. + const rootIds = document.getRootContentCellIdsForMilestone(milestoneIndex); + if (!Array.isArray(rootIds) || rootIds.length === 0) { + console.error( + "[addMilestoneSubdivisionAnchor] No root cells for milestone", + milestoneIndex + ); + vscode.window.showErrorMessage( + `Failed to add subdivision break: milestone ${milestoneIndex} has no content cells.` + ); + return; + } + if ( + typeof cellNumber !== "number" || + !Number.isFinite(cellNumber) || + cellNumber < 2 || + cellNumber > rootIds.length + ) { + console.warn( + "[addMilestoneSubdivisionAnchor] cellNumber out of range:", + { cellNumber, validRange: [2, rootIds.length] } + ); + vscode.window.showWarningMessage( + `Cannot split here — pick a cell between 2 and ${rootIds.length}.` + ); + return; + } + + const newStartCellId = rootIds[cellNumber - 1]; + + // Read existing placements directly from the source milestone's + // metadata (not from the resolved subdivisions) so we preserve any + // source-side names attached to existing placements verbatim. + const milestoneIndexObj = document.buildMilestoneIndex(); + const milestone = milestoneIndexObj.milestones[milestoneIndex]; + if (!milestone) { + console.error( + "[addMilestoneSubdivisionAnchor] Milestone not found at index", + milestoneIndex + ); + vscode.window.showErrorMessage( + `Failed to add subdivision break: milestone not found at index ${milestoneIndex}` + ); + return; + } + const sourceMilestoneCell = document.getCellByIndex(milestone.cellIndex); + const existingPlacements = + (sourceMilestoneCell?.metadata?.data as + | { subdivisions?: { startCellId: string; name?: string; }[]; } + | undefined)?.subdivisions ?? []; + + // Idempotence: if the anchor already exists we quietly no-op so + // repeated clicks don't bounce against validation errors. + if (existingPlacements.some((p) => p.startCellId === newStartCellId)) { + debug( + "[addMilestoneSubdivisionAnchor] Anchor already present; no-op.", + { milestoneIndex, newStartCellId } + ); + await sendMilestoneRefreshToWebview(document, webviewPanel, provider); + return; + } + + const nextPlacements = [ + ...existingPlacements, + { startCellId: newStartCellId }, + ]; + + await commitMilestoneSubdivisionPlacements({ + document, + webviewPanel, + provider, + milestoneIndex, + incomingPlacements: nextPlacements, + logPrefix: "[addMilestoneSubdivisionAnchor]", + errorPrefix: "Failed to add subdivision break", + }); + }, + + updateMilestoneSubdivisionName: async ({ event, document, webviewPanel, provider }) => { + const typedEvent = event as Extract; + debug("updateMilestoneSubdivisionName message received", { event }); + + const { milestoneIndex, subdivisionKey, newName } = typedEvent.content; + + const milestoneIndexObj = document.buildMilestoneIndex(); + const milestone = milestoneIndexObj.milestones[milestoneIndex]; + if (!milestone) { + console.error("[updateMilestoneSubdivisionName] Milestone not found at index", milestoneIndex); + vscode.window.showErrorMessage( + `Failed to rename subdivision: milestone not found at index ${milestoneIndex}` + ); + return; + } - // Note: The custom document change event is automatically fired by updateCellContent - // through the document's _onDidChangeForVsCodeAndWebview event, which the provider - // listens to and fires _onDidChangeCustomDocument. No need to fire it explicitly here. + const milestoneCell = document.getCellByIndex(milestone.cellIndex); + if ( + !milestoneCell || + milestoneCell.metadata?.type !== CodexCellTypes.MILESTONE || + !milestoneCell.metadata?.id + ) { + console.error( + "[updateMilestoneSubdivisionName] Invalid milestone cell", + milestone.cellIndex + ); + vscode.window.showErrorMessage("Failed to rename subdivision: invalid milestone cell."); + return; + } - // Save the document using provider's saveCustomDocument for proper VS Code integration + // Names live on a separate `subdivisionNames` map so source and target + // can be renamed independently. Empty string clears the override. + const existingData = milestoneCell.metadata?.data as + | { + subdivisionNames?: { [k: string]: string; }; + subdivisions?: MilestoneSubdivisionPlacement[]; + } + | undefined; + const existingNames = existingData?.subdivisionNames ?? {}; + const nextNames: { [k: string]: string; } = { ...existingNames }; + const trimmed = typeof newName === "string" ? newName.trim() : ""; + if (trimmed.length === 0) { + delete nextNames[subdivisionKey]; + } else { + nextNames[subdivisionKey] = trimmed; + } + + // Promotion rule: when a SOURCE translator gives a name to a subdivision, + // treat that name as a commitment that this break is meaningful and + // should survive changes to `cellsPerPage` / `maxSubdivisionLength`. + // Auto-generated chunks normally have no entry in `subdivisions`, so we + // add one here. For keys that are already placements we sync `.name` on + // the placement object too, keeping the two naming paths in lockstep + // (the `subdivisionNames` map and the mirror that rides on the + // placement itself). The implicit first subdivision is never promoted: + // it has no placement and can't have one. + const isSource = isSourceFileFlexible(document.uri); + const existingPlacements = Array.isArray(existingData?.subdivisions) + ? existingData.subdivisions + : []; + let promotionPlacements: { startCellId: string; name?: string; }[] | null = null; + if (isSource && subdivisionKey !== FIRST_SUBDIVISION_KEY) { + const normalize = ( + p: MilestoneSubdivisionPlacement | undefined + ): { startCellId: string; name?: string; } | null => { + if (!p || typeof p.startCellId !== "string") return null; + const out: { startCellId: string; name?: string; } = { + startCellId: p.startCellId, + }; + if (typeof p.name === "string" && p.name.length > 0) { + out.name = p.name; + } + return out; + }; + const alreadyPlaced = existingPlacements.some( + (p) => p?.startCellId === subdivisionKey + ); + if (alreadyPlaced) { + // Sync this placement's `.name` with the new override so + // mirror-to-target carries the name on the placement object. + // Other placements are normalized but otherwise untouched. + promotionPlacements = existingPlacements + .map((p) => { + if (!p || typeof p.startCellId !== "string") return null; + if (p.startCellId !== subdivisionKey) return normalize(p); + const updated: { startCellId: string; name?: string; } = { + startCellId: p.startCellId, + }; + if (trimmed.length > 0) updated.name = trimmed; + return updated; + }) + .filter((p): p is { startCellId: string; name?: string; } => p !== null); + } else if (trimmed.length > 0) { + // Promote an auto-chunk to a real placement. The resolver uses + // each auto-chunk's `startCellId` as its key, so the incoming + // key IS a valid root cell id — but we still double-check + // before writing to guard against stale UI state. + const validRootIds = new Set( + document.getRootContentCellIdsForMilestone(milestoneIndex) + ); + if (validRootIds.has(subdivisionKey)) { + const normalized = existingPlacements + .map(normalize) + .filter((p): p is { startCellId: string; name?: string; } => p !== null); + promotionPlacements = [ + ...normalized, + { startCellId: subdivisionKey, name: trimmed }, + ]; + } + } + } + + const cancellationToken = new vscode.CancellationTokenSource().token; + + // Promotion path: write the updated names, then hand off to the shared + // helper which writes `subdivisions`, saves once, and mirrors both + // placements and `subdivisionNamesFromSource` to the paired target + // (including a target-side webview refresh). We `return` early because + // the helper takes over the remainder of the flow. + if (promotionPlacements !== null) { try { - await provider.saveCustomDocument(document, cancellationToken); - debug(`[updateMilestoneValue] Successfully updated and saved milestone in file: ${document.uri.fsPath}`); - vscode.window.showInformationMessage( - `Milestone "${typedEvent.content.newValue}" updated successfully.` + await document.refreshAuthor(); + document.updateCellData(milestoneCell.metadata.id, { + subdivisionNames: nextNames, + }); + } catch (error) { + console.error( + "[updateMilestoneSubdivisionName] Failed to stage name update before promotion:", + error ); - } catch (saveError) { - console.error(`[updateMilestoneValue] Failed to save file ${document.uri.fsPath}:`, saveError); vscode.window.showErrorMessage( - `Failed to save milestone update: ${saveError instanceof Error ? saveError.message : String(saveError)}` + `Failed to rename subdivision: ${error instanceof Error ? error.message : String(error)}` ); return; } + await commitMilestoneSubdivisionPlacements({ + document, + webviewPanel, + provider, + milestoneIndex, + incomingPlacements: promotionPlacements, + logPrefix: "[updateMilestoneSubdivisionName]", + errorPrefix: "Failed to rename subdivision", + }); + return; + } + + // In-memory source mutation only; persistence is hoisted below so + // the source webview refreshes before the disk save completes. + try { + await document.refreshAuthor(); + document.updateCellData(milestoneCell.metadata.id, { subdivisionNames: nextNames }); + debug( + `[updateMilestoneSubdivisionName] Updated name for milestone ${milestoneIndex}, key ${subdivisionKey}` + ); } catch (error) { - // Critical error - milestone update failed - console.error(`[updateMilestoneValue] Critical error updating milestone:`, error); + console.error("[updateMilestoneSubdivisionName] Failed:", error); vscode.window.showErrorMessage( - `Failed to update milestone: ${error instanceof Error ? error.message : String(error)}` + `Failed to rename subdivision: ${error instanceof Error ? error.message : String(error)}` ); return; } - // Always push updated milestone index and cells to webview so the edit appears immediately + // When a SOURCE document renames a subdivision, mirror that name into + // the paired target's `subdivisionNamesFromSource` map so the target + // shows the new label immediately as a fallback. The target's own + // `subdivisionNames` (if any) still wins. We deliberately skip this + // mirror when the rename is happening on a target document — names on + // either side are designed to be independently editable. + let mirroredTargetDocument: CodexCellDocument | null = null; + if (isSourceFileFlexible(document.uri)) { + try { + const pairedUri = provider.getPairedNotebookUri(document.uri); + if (pairedUri) { + const targetDocument = await provider.getOrOpenDocumentForUri(pairedUri); + const targetMilestoneIndexObj = targetDocument.buildMilestoneIndex(); + const targetMilestone = targetMilestoneIndexObj.milestones[milestoneIndex]; + if (targetMilestone) { + const sourceRootIds = document.getRootContentCellIdsForMilestone(milestoneIndex); + const targetRootIds = targetDocument.getRootContentCellIdsForMilestone(milestoneIndex); + const rootsMatch = + sourceRootIds.length === targetRootIds.length && + sourceRootIds.every((id, i) => id === targetRootIds[i]); + if (rootsMatch) { + const targetMilestoneCell = targetDocument.getCellByIndex( + targetMilestone.cellIndex + ); + if (targetMilestoneCell?.metadata?.id) { + const targetData = targetMilestoneCell.metadata?.data as + | { subdivisionNamesFromSource?: { [k: string]: string; }; } + | undefined; + const nextMirror = { ...(targetData?.subdivisionNamesFromSource ?? {}) }; + if (trimmed.length === 0) { + delete nextMirror[subdivisionKey]; + } else { + nextMirror[subdivisionKey] = trimmed; + } + await targetDocument.refreshAuthor(); + targetDocument.updateCellData(targetMilestoneCell.metadata.id, { + subdivisionNamesFromSource: nextMirror, + }); + mirroredTargetDocument = targetDocument; + } + } + } + } + } catch (mirrorError) { + console.error( + "[updateMilestoneSubdivisionName] Failed to mirror to target:", + mirrorError + ); + } + } + await sendMilestoneRefreshToWebview(document, webviewPanel, provider); + + if (mirroredTargetDocument) { + const targetPanel = provider.getWebviewPanelForUri(mirroredTargetDocument.uri); + if (targetPanel) { + try { + await sendMilestoneRefreshToWebview( + mirroredTargetDocument, + targetPanel, + provider + ); + } catch (refreshError) { + console.error( + "[updateMilestoneSubdivisionName] Failed to refresh paired target webview:", + refreshError + ); + } + } + } + + // Persist source + target in parallel AFTER the webviews are + // up-to-date. See `commitSplitMilestoneAtAnchor` for rationale. + const persistenceWork: Promise[] = [ + provider.saveCustomDocument(document, cancellationToken), + ]; + if (mirroredTargetDocument) { + persistenceWork.push( + provider.saveCustomDocument(mirroredTargetDocument, cancellationToken) + ); + } + try { + await Promise.all(persistenceWork); + } catch (persistError) { + console.error( + "[updateMilestoneSubdivisionName] Persistence failed (UI already updated):", + persistError + ); + vscode.window.showErrorMessage( + `Failed to save subdivision rename: ${ + persistError instanceof Error ? persistError.message : String(persistError) + }` + ); + } }, - refreshWebviewAfterMilestoneEdits: async ({ document, webviewPanel, provider }) => { - await sendMilestoneRefreshToWebview(document, webviewPanel, provider); + addMilestoneAtCell: async ({ event, document, webviewPanel, provider }) => { + const typedEvent = event as Extract< + EditorPostMessages, + { command: "addMilestoneAtCell"; } + >; + debug("addMilestoneAtCell message received", { event }); + + const { milestoneIndex, cellNumber } = typedEvent.content; + const rootIds = document.getRootContentCellIdsForMilestone(milestoneIndex); + if ( + !Array.isArray(rootIds) || + rootIds.length === 0 || + typeof cellNumber !== "number" || + !Number.isFinite(cellNumber) || + cellNumber < 2 || + cellNumber > rootIds.length + ) { + console.warn("[addMilestoneAtCell] cellNumber out of range:", { + milestoneIndex, + cellNumber, + rootCount: rootIds?.length ?? 0, + }); + vscode.window.showWarningMessage( + rootIds && rootIds.length >= 2 + ? `Cannot add a milestone here — pick a cell between 2 and ${rootIds.length}.` + : "This milestone is too short to split." + ); + return; + } + + const boundaryCellId = rootIds[cellNumber - 1]; + await commitSplitMilestoneAtAnchor({ + document, + webviewPanel, + provider, + milestoneIndex, + boundaryCellId, + logPrefix: "[addMilestoneAtCell]", + errorPrefix: "Failed to add milestone", + }); + }, + + promoteSubdivisionToMilestone: async ({ event, document, webviewPanel, provider }) => { + const typedEvent = event as Extract< + EditorPostMessages, + { command: "promoteSubdivisionToMilestone"; } + >; + debug("promoteSubdivisionToMilestone message received", { event }); + + const { milestoneIndex, subdivisionKey } = typedEvent.content; + if (subdivisionKey === FIRST_SUBDIVISION_KEY) { + console.warn( + "[promoteSubdivisionToMilestone] Cannot promote the implicit first subdivision (it's already aligned with the milestone start)" + ); + vscode.window.showWarningMessage( + "The first subdivision is already at the milestone boundary." + ); + return; + } + + const rootIds = document.getRootContentCellIdsForMilestone(milestoneIndex); + if (!rootIds.includes(subdivisionKey)) { + console.warn("[promoteSubdivisionToMilestone] Subdivision key not in milestone roots", { + milestoneIndex, + subdivisionKey, + }); + vscode.window.showWarningMessage( + "Cannot promote that subdivision — it does not match a current cell in this milestone." + ); + return; + } + + // Look up the existing placement's resolved name so it becomes the + // promoted milestone's label. Prefer the document-local + // `subdivisionNames` override (matches what the user sees in the + // accordion) and fall back to the placement's inline `.name`. + const milestoneIndexObj = document.buildMilestoneIndex(); + const milestone = milestoneIndexObj.milestones[milestoneIndex]; + const milestoneCell = milestone + ? document.getCellByIndex(milestone.cellIndex) + : undefined; + const existing = milestoneCell ? readMilestoneSubdivisionData(milestoneCell) : { + placements: [], + subdivisionNames: {}, + subdivisionNamesFromSource: {}, + }; + const inlineName = existing.placements.find( + (p) => p.startCellId === subdivisionKey + )?.name; + const promotedLabel = + existing.subdivisionNames[subdivisionKey] || inlineName || undefined; + + await commitSplitMilestoneAtAnchor({ + document, + webviewPanel, + provider, + milestoneIndex, + boundaryCellId: subdivisionKey, + newMilestoneLabel: promotedLabel, + logPrefix: "[promoteSubdivisionToMilestone]", + errorPrefix: "Failed to promote subdivision", + }); + }, + + removeMilestone: async ({ event, document, webviewPanel, provider }) => { + const typedEvent = event as Extract< + EditorPostMessages, + { command: "removeMilestone"; } + >; + debug("removeMilestone message received", { event }); + await commitMergeMilestoneIntoPrevious({ + document, + webviewPanel, + provider, + milestoneIndex: typedEvent.content.milestoneIndex, + preserveBoundary: false, + logPrefix: "[removeMilestone]", + errorPrefix: "Failed to remove milestone", + }); + }, + + demoteMilestoneToSubdivision: async ({ event, document, webviewPanel, provider }) => { + const typedEvent = event as Extract< + EditorPostMessages, + { command: "demoteMilestoneToSubdivision"; } + >; + debug("demoteMilestoneToSubdivision message received", { event }); + await commitMergeMilestoneIntoPrevious({ + document, + webviewPanel, + provider, + milestoneIndex: typedEvent.content.milestoneIndex, + preserveBoundary: true, + logPrefix: "[demoteMilestoneToSubdivision]", + errorPrefix: "Failed to demote milestone", + }); }, updateNotebookMetadata: async ({ event, document, webviewPanel, provider }) => { @@ -3380,9 +5025,19 @@ const messageHandlers: Record Promise("maxSubdivisionLength", 0); + const maxSubdivisionLength = + typeof maxSubdivisionLengthRaw === "number" && maxSubdivisionLengthRaw > 0 + ? Math.floor(maxSubdivisionLengthRaw) + : 0; // Get cells for the requested milestone/subsection - const cells = document.getCellsForMilestone(milestoneIndex, subsectionIndex, cellsPerPage); + const cells = document.getCellsForMilestone( + milestoneIndex, + subsectionIndex, + cellsPerPage, + maxSubdivisionLength + ); // Get all cells in the milestone for footnote offset calculation const allCellsInMilestone = document.getAllCellsForMilestone(milestoneIndex); @@ -3862,3 +5517,10 @@ export async function scanForAudioAttachments( return audioAttachments; } + +/** + * Test-only re-export of the internal handler map. Production code should + * continue to route messages through `handleMessages`; this hook just lets + * unit tests exercise a single handler without standing up a full webview. + */ +export const __testOnlyMessageHandlers = messageHandlers; diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index a172d83de..4820987cf 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -48,7 +48,7 @@ import { isSourceFileFlexible, isMatchingFilePair as isMatchingFilePairUtil, } from "../../utils/fileTypeUtils"; -import { getCorrespondingSourceUri } from "../../utils/codexNotebookUtils"; +import { getCorrespondingCodexUri, getCorrespondingSourceUri } from "../../utils/codexNotebookUtils"; // Enable debug logging if needed const DEBUG_MODE = false; @@ -123,6 +123,41 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider("maxSubdivisionLength", 0); + return typeof raw === "number" && raw > 0 ? Math.floor(raw) : 0; + } + + /** + * User opt-in for the milestone-placement editing controls (add / remove + * / promote / demote). Off by default — the feature is gated because it + * restructures the document. Pushed to webviews on initial paint and on + * the workspace configuration change event below so the controls toggle + * live without a reload. + */ + private get ENABLE_MILESTONE_PLACEMENT_EDITING(): boolean { + const config = vscode.workspace.getConfiguration("codex-editor-extension"); + return config.get("enableMilestonePlacementEditing", false); + } + private bumpDocumentRevision(documentUri: string): number { const next = (this.documentRevisions.get(documentUri) ?? 0) + 1; this.documentRevisions.set(documentUri, next); @@ -295,14 +330,77 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { - // Use custom message type for cells per page update + this.webviewPanels.forEach((panel, docUri) => { safePostMessageToPanel(panel, { type: "updateCellsPerPage", cellsPerPage: newCellsPerPage, }); + const document = this.documents.get(docUri); + if (document) { + sendMilestoneRefreshToWebview(document, panel, this).catch( + (err) => + console.error( + "[CodexCellEditorProvider] Failed to refresh milestone after cellsPerPage change:", + err + ) + ); + } + }); + } + + if (e.affectsConfiguration("codex-editor-extension.maxSubdivisionLength")) { + // No dedicated webview message for this setting yet — the new + // threshold is consumed by the document-side + // `buildMilestoneIndex` call inside `sendMilestoneRefreshToWebview`, + // which rebuilds the subdivision layout and pushes the fresh + // milestone index plus current page's cells to each open webview. + this.webviewPanels.forEach((panel, docUri) => { + const document = this.documents.get(docUri); + if (document) { + sendMilestoneRefreshToWebview(document, panel, this).catch( + (err) => + console.error( + "[CodexCellEditorProvider] Failed to refresh milestone after maxSubdivisionLength change:", + err + ) + ); + } + }); + } + + if ( + e.affectsConfiguration( + "codex-editor-extension.useSubdivisionNumberLabels" + ) + ) { + // Push the new preference to all open webviews so subdivision + // labels switch between name/number mode without a reload. + const newPref = this.USE_SUBDIVISION_NUMBER_LABELS; + this.webviewPanels.forEach((panel) => { + safePostMessageToPanel(panel, { + type: "updateSubdivisionLabelPreference", + useSubdivisionNumberLabels: newPref, + }); + }); + } + + if ( + e.affectsConfiguration( + "codex-editor-extension.enableMilestonePlacementEditing" + ) + ) { + const newPref = this.ENABLE_MILESTONE_PLACEMENT_EDITING; + this.webviewPanels.forEach((panel) => { + safePostMessageToPanel(panel, { + type: "updateMilestonePlacementEditingPreference", + enableMilestonePlacementEditing: newPref, + }); }); } }); @@ -760,7 +858,10 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { @@ -828,7 +929,12 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { + const uriString = uri.toString(); + for (const [panelUri] of this.webviewPanels.entries()) { + if (this.isMatchingFilePair(uriString, panelUri) && panelUri === uriString) { + // Reuse the document backing the open panel. openCustomDocument + // returns the cached instance when one exists for this exact URI. + return await this.openCustomDocument( + vscode.Uri.parse(panelUri), + {}, + new vscode.CancellationTokenSource().token + ); + } + } + return await this.openCustomDocument(uri, {}, new vscode.CancellationTokenSource().token); + } + private updateTextDirection( webviewPanel: vscode.WebviewPanel, document: CodexCellDocument @@ -2484,7 +2645,10 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { @@ -2553,7 +2717,12 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider("maxSubdivisionLength", 0); + const maxSubdivisionLength = + typeof maxSubdivisionLengthRaw === "number" && maxSubdivisionLengthRaw > 0 + ? Math.floor(maxSubdivisionLengthRaw) + : 0; // Get the corresponding source URI const codexUri = vscode.Uri.parse(uri); @@ -2759,14 +2936,14 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider; + }): { cellId: string; cellIndex: number; } { + const { newCellId, referenceCellId, valueOverride, initialData } = opts; + const indexOfReferenceCell = this._documentData.cells.findIndex( + (cell) => cell.metadata?.id === referenceCellId + ); + if (indexOfReferenceCell === -1) { + throw new Error( + `Could not find reference cell ${referenceCellId} for milestone insertion` + ); + } + + // Use the reference cell's metadata to derive a sensible default + // label ("BookName ChapterNumber"). Caller can override via + // `valueOverride` — typical when promoting a named subdivision. + const referenceCell = this._documentData.cells[indexOfReferenceCell]; + // Milestone ordinals here are purely for the fallback chapter label + // when no structured chapter metadata is available; we count existing + // (non-deleted) milestone cells before the insertion point + 1. + let ordinal = 1; + for (let i = 0; i < indexOfReferenceCell; i++) { + const c = this._documentData.cells[i]; + if ( + c.metadata?.type === CodexCellTypes.MILESTONE && + c.metadata?.data?.deleted !== true + ) { + ordinal++; + } + } + + const payload = buildMilestoneCellPayload({ + referenceCell, + milestoneOrdinal: ordinal, + author: this._author, + uuid: newCellId, + valueOverride, + initialData, + }); + + // Splice in BEFORE the reference cell so it becomes the new + // milestone's first content cell. + this._documentData.cells.splice(indexOfReferenceCell, 0, payload as CustomNotebookCellData); + + // Cell-list shape changed → milestone partition is stale. + this.invalidateMilestoneIndexCache(); + + const insertedId = payload.metadata.id; + this._edits.push({ + type: "addCell", + newCellId: insertedId, + referenceCellId, + cellType: CodexCellTypes.MILESTONE, + data: payload.metadata.data, + }); + + this._isDirty = true; + this._dirtyCellIds.add(insertedId); + this._onDidChangeForVsCodeAndWebview.fire({ + edits: [ + { + newCellId: insertedId, + referenceCellId, + cellType: CodexCellTypes.MILESTONE, + data: payload.metadata.data, + }, + ], + }); + + return { cellId: insertedId, cellIndex: indexOfReferenceCell }; + } + // Method to update notebook metadata public updateNotebookMetadata(newMetadata: Partial) { if (!this._documentData.metadata) { @@ -1412,6 +1506,7 @@ export class CodexCellDocument implements vscode.CustomDocument { private invalidateMilestoneIndexCache(): void { this._cachedMilestoneIndex = null; this._cachedMilestoneIndexCellsPerPage = null; + this._cachedMilestoneIndexMaxSubdivisionLength = null; this._cachedMilestoneIndexCellCount = 0; this._lastUpdatedMilestoneIndexCellCount = 0; } @@ -1442,21 +1537,122 @@ export class CodexCellDocument implements vscode.CustomDocument { return false; } + /** + * Returns the ordered list of root content cell IDs belonging to a milestone, + * in document order. Useful for anchor validation (incoming + * `updateMilestoneSubdivisions` writes) and for source↔target alignment + * sanity checks before mirroring placements. + * + * Returns an empty array if the milestone index is out of bounds. + */ + public getRootContentCellIdsForMilestone(milestoneIndex: number): string[] { + const cells = this._documentData.cells || []; + const info = this.buildMilestoneIndex(); + if (milestoneIndex < 0 || milestoneIndex >= info.milestones.length) return []; + const milestone = info.milestones[milestoneIndex]; + const next = info.milestones[milestoneIndex + 1]; + const end = next ? next.cellIndex : cells.length; + return this.getRootContentCellIdsInRange(milestone.cellIndex, end); + } + + /** + * Returns the ordered list of root content cell IDs within the given index + * range. Root content cells are non-milestone, non-paratext, non-deleted cells + * without a `parentId`. Pagination (both arithmetic and subdivision-based) + * operates over these roots; children and paratext are attached during slicing. + */ + private getRootContentCellIdsInRange( + startCellIndex: number, + endCellIndex: number + ): string[] { + const cells = this._documentData.cells || []; + const rootIds: string[] = []; + for (let i = startCellIndex; i < endCellIndex; i++) { + const cell = cells[i]; + if ( + cell.metadata?.type !== CodexCellTypes.MILESTONE && + cell.metadata?.type !== CodexCellTypes.PARATEXT && + cell.metadata?.data?.deleted !== true + ) { + const parentId = + cell.metadata?.parentId ?? + (cell.metadata?.data as { parentId?: string; } | undefined)?.parentId; + if (!parentId) { + const id = cell.metadata?.id; + if (id) rootIds.push(id); + } + } + } + return rootIds; + } + + /** + * Resolves the subdivisions for a single milestone by index. Reads stored + * placements from the milestone cell's `metadata.data.subdivisions` and + * overrides from `metadata.data.subdivisionNames`. When either is absent the + * arithmetic fallback is used, preserving legacy 50-cell pagination. + * + * `cellIndex` must be the absolute index of the milestone cell within + * `_documentData.cells`; `nextMilestoneCellIndex` is the index of the next + * milestone cell (or `cells.length`) and defines the range end. + */ + private resolveSubdivisionsForMilestoneCell( + cellIndex: number, + nextMilestoneCellIndex: number, + cellsPerPage: number, + maxSubdivisionLength?: number + ): SubdivisionInfo[] { + const cells = this._documentData.cells || []; + const rootContentCellIds = this.getRootContentCellIdsInRange( + cellIndex, + nextMilestoneCellIndex + ); + const milestoneCell = cells[cellIndex]; + const data = milestoneCell?.metadata?.data as + | { + subdivisions?: MilestoneSubdivisionPlacement[]; + subdivisionNames?: { [key: string]: string; }; + subdivisionNamesFromSource?: { [key: string]: string; }; + } + | undefined; + return resolveSubdivisions({ + rootContentCellIds, + placements: data?.subdivisions, + nameOverrides: data?.subdivisionNames, + // `subdivisionNamesFromSource` only ever appears on TARGET milestone + // cells (mirrored by source-side handlers). On source documents the + // field is absent so passing it through is a no-op. Either way the + // resolver consults it as a fallback after the document's own map. + fallbackNameOverrides: data?.subdivisionNamesFromSource, + cellsPerPage, + maxSubdivisionLength, + }); + } + /** * Builds a milestone index from the document cells. * This index is cached and reused until cells are modified. * * @param cellsPerPage Number of cells per page for sub-pagination within milestones + * @param maxSubdivisionLength Optional cap above which a stretch between + * user-defined breaks gets sub-chunked by `cellsPerPage`. Pass 0/undefined + * for the legacy "always chunk past a page" behaviour. * @returns MilestoneIndex containing milestone information and pagination settings */ - public buildMilestoneIndex(cellsPerPage: number = 50): MilestoneIndex { + public buildMilestoneIndex( + cellsPerPage: number = 50, + maxSubdivisionLength: number = 0 + ): MilestoneIndex { const cells = this._documentData.cells || []; const currentCellCount = cells.length; - // Check if we can use the cached index + // Check if we can use the cached index. The cache key now also + // includes `maxSubdivisionLength` so flipping that workspace setting + // produces a fresh subdivision layout instead of stale slices. if ( this._cachedMilestoneIndex !== null && this._cachedMilestoneIndexCellsPerPage === cellsPerPage && + this._cachedMilestoneIndexMaxSubdivisionLength === maxSubdivisionLength && this._cachedMilestoneIndexCellCount === currentCellCount ) { return this._cachedMilestoneIndex; @@ -1557,13 +1753,24 @@ export class CodexCellDocument implements vscode.CustomDocument { } } + const virtualMilestone: MilestoneInfo = { + index: 0, + cellIndex: 0, + value: "1", + cellCount: totalContentCells, + }; + // Virtual milestone has no backing milestone cell; fall back to + // arithmetic subdivisions across the full cell range. + virtualMilestone.subdivisions = resolveSubdivisions({ + rootContentCellIds: this.getRootContentCellIdsInRange(0, cells.length), + placements: undefined, + nameOverrides: undefined, + cellsPerPage, + maxSubdivisionLength, + }); + const result: MilestoneIndex = { - milestones: [{ - index: 0, - cellIndex: 0, - value: "1", - cellCount: totalContentCells, - }], + milestones: [virtualMilestone], totalCells: totalContentCells, cellsPerPage, }; @@ -1571,11 +1778,26 @@ export class CodexCellDocument implements vscode.CustomDocument { // Cache the result this._cachedMilestoneIndex = result; this._cachedMilestoneIndexCellsPerPage = cellsPerPage; + this._cachedMilestoneIndexMaxSubdivisionLength = maxSubdivisionLength; this._cachedMilestoneIndexCellCount = currentCellCount; return result; } + // Attach resolved subdivisions to each milestone so slicing APIs and the + // webview share a single source of truth. + for (let i = 0; i < milestones.length; i++) { + const milestone = milestones[i]; + const nextMilestone = milestones[i + 1]; + const endCellIndex = nextMilestone ? nextMilestone.cellIndex : cells.length; + milestone.subdivisions = this.resolveSubdivisionsForMilestoneCell( + milestone.cellIndex, + endCellIndex, + cellsPerPage, + maxSubdivisionLength + ); + } + const result: MilestoneIndex = { milestones, totalCells: totalContentCells, @@ -1585,6 +1807,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Cache the result this._cachedMilestoneIndex = result; this._cachedMilestoneIndexCellsPerPage = cellsPerPage; + this._cachedMilestoneIndexMaxSubdivisionLength = maxSubdivisionLength; this._cachedMilestoneIndexCellCount = currentCellCount; return result; @@ -1593,8 +1816,13 @@ export class CodexCellDocument implements vscode.CustomDocument { /** * Updates the database with milestone indices for all cells. * This should be called after buildMilestoneIndex() to persist the milestone indices. + * + * Pass `force: true` for structural edits (insert/soft-delete of a + * milestone cell) where the cell COUNT may not change but the per-cell + * `milestoneIndex` assignment does. The default fast path keys on cell + * count alone and would otherwise skip the reflush. */ - public async updateCellMilestoneIndices(): Promise { + public async updateCellMilestoneIndices(options?: { force?: boolean; }): Promise { if (!this.refreshIndexManager()) { console.warn(`[CodexDocument] Index manager not available for milestone index update`); return; @@ -1603,9 +1831,12 @@ export class CodexCellDocument implements vscode.CustomDocument { const cells = this._documentData.cells || []; const currentCellCount = cells.length; - // Optimization: Skip update if milestone indices haven't changed - // If cache is valid and cell count matches last update, indices haven't changed + // Optimization: Skip update if milestone indices haven't changed. + // Structural edits that mutate `milestoneIndex` without changing the + // cell COUNT (notably soft-deleting a milestone cell) must opt out via + // `force` so the SQLite mirror doesn't drift from the in-memory state. if ( + !options?.force && this._cachedMilestoneIndex !== null && this._cachedMilestoneIndexCellCount === currentCellCount && this._lastUpdatedMilestoneIndexCellCount === currentCellCount @@ -1689,7 +1920,7 @@ export class CodexCellDocument implements vscode.CustomDocument { * @param cellsPerPage Number of cells per page for sub-pagination (default: 50) * @returns An object with milestoneIndex and subsectionIndex, or null if not found */ - public findMilestoneAndSubsectionForCell(cellId: string, cellsPerPage: number = 50): { milestoneIndex: number; subsectionIndex: number; } | null { + public findMilestoneAndSubsectionForCell(cellId: string, cellsPerPage: number = 50, maxSubdivisionLength: number = 0): { milestoneIndex: number; subsectionIndex: number; } | null { const cells = this._documentData.cells || []; // Normalize cellId by trimming whitespace @@ -1707,7 +1938,7 @@ export class CodexCellDocument implements vscode.CustomDocument { } // Build milestone index to get milestone information - const milestoneInfo = this.buildMilestoneIndex(cellsPerPage); + const milestoneInfo = this.buildMilestoneIndex(cellsPerPage, maxSubdivisionLength); // Find which milestone this cell belongs to for (let i = 0; i < milestoneInfo.milestones.length; i++) { @@ -1750,10 +1981,17 @@ export class CodexCellDocument implements vscode.CustomDocument { const parentId = cell.metadata?.parentId ?? (cell.metadata?.data as { parentId?: string; } | undefined)?.parentId; cellRootIndex = parentId != null ? cellIdToRootIndex.get(parentId) : undefined; } - const subsectionIndex = - cellRootIndex !== undefined - ? Math.max(0, Math.floor(cellRootIndex / cellsPerPage)) - : 0; + // Use resolved subdivisions (custom or arithmetic) to locate the + // right subsection. When no rootIndex could be derived (e.g. the + // located cell is a milestone boundary), fall back to 0. + const subdivisions = milestone.subdivisions ?? []; + let subsectionIndex = 0; + if (cellRootIndex !== undefined && subdivisions.length > 0) { + const found = findSubdivisionIndexForRoot(subdivisions, cellRootIndex); + subsectionIndex = found >= 0 ? found : 0; + } else if (cellRootIndex !== undefined) { + subsectionIndex = Math.max(0, Math.floor(cellRootIndex / cellsPerPage)); + } return { milestoneIndex: i, subsectionIndex }; } @@ -1886,7 +2124,8 @@ export class CodexCellDocument implements vscode.CustomDocument { milestoneIndex: number, cellsPerPage: number = 50, minimumValidationsRequired: number = 1, - minimumAudioValidationsRequired: number = 1 + minimumAudioValidationsRequired: number = 1, + maxSubdivisionLength: number = 0 ): Record = {}; const cells = this._documentData.cells || []; - const milestoneInfo = this.buildMilestoneIndex(cellsPerPage); + const milestoneInfo = this.buildMilestoneIndex(cellsPerPage, maxSubdivisionLength); // Validate milestone index if (milestoneIndex < 0 || milestoneIndex >= milestoneInfo.milestones.length) { @@ -1937,19 +2176,26 @@ export class CodexCellDocument implements vscode.CustomDocument { contentCells.push(quillContent); } - // Use root-based subsections to match getCellsForMilestone pagination + // Use root-based subsections to match getCellsForMilestone pagination. + // When the milestone has user-defined subdivisions, use them; otherwise + // fall back to arithmetic chunks of `cellsPerPage`. const getContentCellParentId = (c: QuillCellContent) => (c.metadata?.parentId as string | undefined) ?? (c.data?.parentId as string | undefined); const rootContentCells = contentCells.filter((c) => !getContentCellParentId(c)); - const totalSubsections = Math.ceil(rootContentCells.length / cellsPerPage); + const subdivisions = milestone.subdivisions ?? resolveSubdivisions({ + rootContentCellIds: rootContentCells.map((c) => c.cellMarkers[0]).filter(Boolean), + placements: undefined, + nameOverrides: undefined, + cellsPerPage, + maxSubdivisionLength, + }); + const totalSubsections = Math.max(1, subdivisions.length); // Calculate progress for each subsection for (let subsectionIdx = 0; subsectionIdx < totalSubsections; subsectionIdx++) { - const startRootIndex = subsectionIdx * cellsPerPage; - const endRootIndex = Math.min( - startRootIndex + cellsPerPage, - rootContentCells.length - ); + const subdivision = subdivisions[subsectionIdx]; + const startRootIndex = subdivision?.startRootIndex ?? 0; + const endRootIndex = subdivision?.endRootIndex ?? rootContentCells.length; const rootsOnSubsection = rootContentCells.slice(startRootIndex, endRootIndex); const contentCellIdsForSubsection = new Set( rootsOnSubsection.map((c) => c.cellMarkers[0]) @@ -2082,10 +2328,11 @@ export class CodexCellDocument implements vscode.CustomDocument { public getCellsForMilestone( milestoneIndex: number, subsectionIndex: number = 0, - cellsPerPage: number = 50 + cellsPerPage: number = 50, + maxSubdivisionLength: number = 0 ): QuillCellContent[] { const cells = this._documentData.cells || []; - const milestoneInfo = this.buildMilestoneIndex(cellsPerPage); + const milestoneInfo = this.buildMilestoneIndex(cellsPerPage, maxSubdivisionLength); // Validate milestone index if (milestoneIndex < 0 || milestoneIndex >= milestoneInfo.milestones.length) { @@ -2141,17 +2388,25 @@ export class CodexCellDocument implements vscode.CustomDocument { // Paginate by root content cells only, so adding a child (e.g. to cell 44) does not bump // the last root (e.g. cell 50) to the next page. Each page shows N roots + all their descendants. const rootContentCells = contentCells.filter((c) => !getContentCellParentId(c)); - const totalSubsections = Math.ceil(rootContentCells.length / cellsPerPage); + // Subdivisions computed by buildMilestoneIndex drive both the legacy + // arithmetic chunking (as "auto" subdivisions) and any user-defined custom + // breaks. Using them here keeps slicing, counting, and webview rendering + // consistent. + const subdivisions = milestone.subdivisions ?? resolveSubdivisions({ + rootContentCellIds: rootContentCells.map((c) => c.cellMarkers[0]).filter(Boolean), + placements: undefined, + nameOverrides: undefined, + cellsPerPage, + maxSubdivisionLength, + }); + const totalSubsections = Math.max(1, subdivisions.length); const validSubsectionIndex = Math.min( Math.max(0, subsectionIndex), Math.max(0, totalSubsections - 1) ); - - const startRootIndex = validSubsectionIndex * cellsPerPage; - const endRootIndex = Math.min( - startRootIndex + cellsPerPage, - rootContentCells.length - ); + const activeSubdivision = subdivisions[validSubsectionIndex]; + const startRootIndex = activeSubdivision?.startRootIndex ?? 0; + const endRootIndex = activeSubdivision?.endRootIndex ?? rootContentCells.length; const rootsOnPage = rootContentCells.slice(startRootIndex, endRootIndex); // Include roots on this page and all their descendant content cells (children, grandchildren, etc.) @@ -2284,34 +2539,19 @@ export class CodexCellDocument implements vscode.CustomDocument { * @param cellsPerPage Number of cells per page * @returns Number of subsections (pages) for this milestone */ - public getSubsectionCountForMilestone(milestoneIndex: number, cellsPerPage: number = 50): number { - const cells = this._documentData.cells || []; - const milestoneInfo = this.buildMilestoneIndex(cellsPerPage); + public getSubsectionCountForMilestone(milestoneIndex: number, cellsPerPage: number = 50, maxSubdivisionLength: number = 0): number { + const milestoneInfo = this.buildMilestoneIndex(cellsPerPage, maxSubdivisionLength); if (milestoneIndex < 0 || milestoneIndex >= milestoneInfo.milestones.length) { return 0; } const milestone = milestoneInfo.milestones[milestoneIndex]; - const nextMilestone = milestoneInfo.milestones[milestoneIndex + 1]; - const startCellIndex = milestone.cellIndex; - const endCellIndex = nextMilestone ? nextMilestone.cellIndex : cells.length; - - let rootContentCount = 0; - for (let i = startCellIndex; i < endCellIndex; i++) { - const cell = cells[i]; - if ( - cell.metadata?.type !== CodexCellTypes.MILESTONE && - cell.metadata?.type !== CodexCellTypes.PARATEXT && - cell.metadata?.data?.deleted !== true - ) { - const parentId = cell.metadata?.parentId ?? (cell.metadata?.data as { parentId?: string; } | undefined)?.parentId; - if (!parentId) { - rootContentCount++; - } - } - } - return Math.ceil(rootContentCount / cellsPerPage) || 1; + // Prefer the resolved subdivision list (includes custom placements). It is + // always non-empty when the milestone has root content cells, and empty + // when the milestone is empty; treat empty milestones as 1 subsection for + // back-compat with prior behavior. + return Math.max(1, milestone.subdivisions?.length ?? 0); } public updateCellLabel(cellId: string, newLabel: string) { @@ -3053,7 +3293,17 @@ export class CodexCellDocument implements vscode.CustomDocument { // Check if this is a milestone cell and if we're modifying data that affects milestone index const isMilestoneCell = cellToUpdate.metadata?.type === CodexCellTypes.MILESTONE; const isModifyingDeletedFlag = 'deleted' in newData; - const shouldInvalidateCache = isMilestoneCell && isModifyingDeletedFlag; + // Subdivision-related changes alter pagination, so the cached index must + // be invalidated alongside the deleted-flag case. Name-only overrides + // (both local and the source-mirror fallback) also flow through this + // path so that resolved `MilestoneInfo.subdivisions` picks up new names + // on next render. + const isModifyingSubdivisions = + 'subdivisions' in newData || + 'subdivisionNames' in newData || + 'subdivisionNamesFromSource' in newData; + const shouldInvalidateCache = + isMilestoneCell && (isModifyingDeletedFlag || isModifyingSubdivisions); // Ensure metadata exists if (!this._documentData.cells[indexOfCellToUpdate].metadata) { diff --git a/src/providers/codexCellEditorProvider/utils/subdivisionUtils.ts b/src/providers/codexCellEditorProvider/utils/subdivisionUtils.ts new file mode 100644 index 000000000..a9b154cea --- /dev/null +++ b/src/providers/codexCellEditorProvider/utils/subdivisionUtils.ts @@ -0,0 +1,464 @@ +import type { + MilestoneSubdivisionPlacement, + SubdivisionInfo, +} from "../../../../types"; + +/** + * Stable key used for the implicit first subdivision of a milestone. Kept public so + * target-side name override maps can reference it without repeating the literal. + */ +export const FIRST_SUBDIVISION_KEY = "__start__"; + +export interface ResolveSubdivisionsOptions { + /** + * Ordered IDs of the milestone's root content cells (non-milestone, non-paratext, + * non-deleted cells without a `parentId`). These form the pagination axis that + * subdivision anchors are resolved against. + */ + rootContentCellIds: string[]; + /** + * User-defined break anchors. Typically sourced from + * `milestoneCell.metadata.data.subdivisions` on the source document. The + * implicit first subdivision (starting at root index 0) is never listed here — + * only subsequent breaks. + */ + placements?: MilestoneSubdivisionPlacement[]; + /** + * Document-local name overrides, keyed by subdivision key (typically + * `startCellId`, or `FIRST_SUBDIVISION_KEY` for the implicit first subdivision). + * Takes precedence over every other name source. + */ + nameOverrides?: { [key: string]: string; }; + /** + * Mirrored-from-source name overrides used as a fallback when the document + * has no local override for a key. Lets target documents inherit source-side + * names without surrendering the ability to set their own. Same key shape as + * `nameOverrides`. + */ + fallbackNameOverrides?: { [key: string]: string; }; + /** + * Arithmetic chunk size used both for the no-placements fallback AND for + * sub-chunking long stretches between user-defined breaks. + */ + cellsPerPage: number; + /** + * Maximum stretch length (in root cells) the resolver will leave unsplit + * between two user-defined breaks. Stretches longer than this are split + * into chunks of `cellsPerPage`. Pass `0` / `undefined` to use + * `cellsPerPage` itself as the threshold (the legacy "always chunk past + * a page" behaviour). When custom placements are absent the threshold is + * applied to the whole milestone the same way. + */ + maxSubdivisionLength?: number; + /** + * Default display name for the implicit first subdivision when no custom name is + * set. Defaults to undefined (callers typically format a numbered fallback like + * "1–50"). + */ + firstSubdivisionDefaultName?: string; +} + +/** + * Resolves a milestone's root-content-cell range into a list of `SubdivisionInfo` + * items ready for rendering. + * + * Behaviour: + * - When `placements` is empty/undefined, returns arithmetic chunks of + * `cellsPerPage`. This matches the legacy 50-cell pagination exactly when + * `cellsPerPage === 50`. + * - Placements with a `startCellId` that no longer exists in `rootContentCellIds` + * are silently pruned (the anchored cell was deleted, merged away, etc.). + * - Placements are sorted by their resolved root-index so callers don't need to + * keep them ordered on disk. + * - Duplicate placements pointing at the same root index are collapsed. + * - When the last subdivision has a user-assigned name and additional root cells + * exist beyond it in a way not covered by a subsequent placement, no trailing + * auto-subdivision is added (the named subdivision absorbs the tail, consistent + * with expected naming semantics). When the last subdivision is unnamed, the + * tail is likewise absorbed. A trailing auto-subdivision is emitted ONLY when + * the only placements are the implicit first one and no root content cells + * remain uncovered — i.e. never. The tail-append semantics for new cells after + * the last explicit named break are handled at write time, not at resolve time. + * + * @returns Ordered, non-overlapping, fully-covering list of subdivisions. Always + * contains at least one item when `rootContentCellIds.length > 0`; returns an + * empty array when the milestone has zero root content cells. + */ +export function resolveSubdivisions( + opts: ResolveSubdivisionsOptions +): SubdivisionInfo[] { + const { + rootContentCellIds, + placements, + nameOverrides, + fallbackNameOverrides, + cellsPerPage, + maxSubdivisionLength, + firstSubdivisionDefaultName, + } = opts; + + const totalRoots = rootContentCellIds.length; + if (totalRoots === 0) { + return []; + } + + // Two-tier name resolution: a document's local override always wins, with the + // mirrored-source fallback acting as the default when the local map is silent. + // Empty strings are treated as "not set" so a stray "" never masks a real name. + const pickName = ( + map: { [k: string]: string; } | undefined, + key: string + ): string | undefined => { + if (!map) return undefined; + const value = map[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; + }; + const resolveOverride = (key: string): string | undefined => + pickName(nameOverrides, key) ?? pickName(fallbackNameOverrides, key); + + // Threshold rules (shared across the no-placements and with-placements branches): + // - `pageSize` is the chunk granularity used when we *do* split. + // - `threshold` is the maximum stretch length we leave unsplit. + // - When `maxSubdivisionLength` is positive we honour it directly so users + // can preserve uneven logical pages; otherwise threshold === pageSize, + // matching the legacy "split anything larger than a page" behaviour. + const pageSize = Math.max(1, cellsPerPage); + const threshold = + typeof maxSubdivisionLength === "number" && maxSubdivisionLength > 0 + ? maxSubdivisionLength + : pageSize; + + /** + * Expands a single stretch [startRootIndex, endRootIndex) into one or more + * `SubdivisionInfo` entries, sub-chunking by `pageSize` when the stretch is + * longer than `threshold`. The first chunk inherits the parent identity + * (key/name/source/startCellId); subsequent chunks are auto-derived. + */ + const expandStretch = (parent: SubdivisionInfo): SubdivisionInfo[] => { + const length = parent.endRootIndex - parent.startRootIndex; + if (length <= threshold) return [parent]; + const out: SubdivisionInfo[] = []; + let cursor = parent.startRootIndex; + let isFirst = true; + while (cursor < parent.endRootIndex) { + const endRoot = Math.min(cursor + pageSize, parent.endRootIndex); + if (isFirst) { + out.push({ ...parent, endRootIndex: endRoot }); + isFirst = false; + } else { + const startCellId = rootContentCellIds[cursor]; + const key = startCellId ?? `auto-${cursor}`; + out.push({ + index: 0, // re-indexed by the caller after all stretches expand + startRootIndex: cursor, + endRootIndex: endRoot, + key, + startCellId, + name: resolveOverride(key), + source: "auto", + }); + } + cursor = endRoot; + } + return out; + }; + + /** Re-numbers `index` on the final expanded subdivision list. */ + const reindex = (entries: SubdivisionInfo[]): SubdivisionInfo[] => + entries.map((entry, idx) => ({ ...entry, index: idx })); + + // Branch 1: no user-defined breaks → treat the whole milestone as one stretch + // and expand it. This keeps the implicit-first identity stable when the + // milestone fits in a single page (pure auto, no chunking) while still + // chunking large unbroken milestones to match legacy pagination. + if (!placements || placements.length === 0) { + const wholeMilestone: SubdivisionInfo = { + index: 0, + startRootIndex: 0, + endRootIndex: totalRoots, + key: FIRST_SUBDIVISION_KEY, + startCellId: rootContentCellIds[0], + name: + resolveOverride(FIRST_SUBDIVISION_KEY) ?? + firstSubdivisionDefaultName, + source: "auto", + }; + return reindex(expandStretch(wholeMilestone)); + } + + // Map from rootCellId → root index for fast anchor resolution. + const rootIdToIndex = new Map(); + for (let i = 0; i < rootContentCellIds.length; i++) { + rootIdToIndex.set(rootContentCellIds[i], i); + } + + // Resolve each placement → { rootIndex, name }. Skip anchors that reference a + // non-existent root cell or that resolve to index 0 (those collide with the + // implicit first subdivision; keep the placement's name for the first one). + interface ResolvedAnchor { rootIndex: number; name?: string; key: string; startCellId?: string; } + const resolved: ResolvedAnchor[] = []; + let firstSubdivisionName: string | undefined; + const seenIndices = new Set(); + for (const placement of placements) { + if (!placement || typeof placement.startCellId !== "string") continue; + const rootIndex = rootIdToIndex.get(placement.startCellId); + if (rootIndex === undefined) continue; // stale anchor — silently pruned + if (rootIndex === 0) { + // User anchored the "first break" at the first root cell — treat its + // name as naming the implicit first subdivision. + if (!firstSubdivisionName && placement.name) { + firstSubdivisionName = placement.name; + } + continue; + } + if (seenIndices.has(rootIndex)) continue; + seenIndices.add(rootIndex); + resolved.push({ + rootIndex, + name: placement.name, + key: placement.startCellId, + startCellId: placement.startCellId, + }); + } + + // Sort by root index so users aren't forced to write placements in order. + resolved.sort((a, b) => a.rootIndex - b.rootIndex); + + // Compose the user-break stretches: implicit first + each resolved break. + // Each entry is then expanded if its length exceeds the threshold. + const stretches: SubdivisionInfo[] = []; + const firstEnd = resolved.length > 0 ? resolved[0].rootIndex : totalRoots; + stretches.push({ + index: 0, // placeholder, reindexed at the end + startRootIndex: 0, + endRootIndex: firstEnd, + key: FIRST_SUBDIVISION_KEY, + startCellId: rootContentCellIds[0], + name: + resolveOverride(FIRST_SUBDIVISION_KEY) ?? + firstSubdivisionName ?? + firstSubdivisionDefaultName, + source: resolved.length > 0 ? "custom" : "auto", + }); + + for (let i = 0; i < resolved.length; i++) { + const anchor = resolved[i]; + const next = resolved[i + 1]; + const endRootIndex = next ? next.rootIndex : totalRoots; + stretches.push({ + index: 0, // placeholder + startRootIndex: anchor.rootIndex, + endRootIndex, + key: anchor.key, + startCellId: anchor.startCellId, + name: resolveOverride(anchor.key) ?? anchor.name, + source: "custom", + }); + } + + const expanded: SubdivisionInfo[] = []; + for (const stretch of stretches) { + for (const piece of expandStretch(stretch)) { + expanded.push(piece); + } + } + return reindex(expanded); +} + +/** + * Finds the subdivision that contains `rootIndex`. Returns -1 if none match. + */ +export function findSubdivisionIndexForRoot( + subdivisions: SubdivisionInfo[], + rootIndex: number +): number { + for (let i = 0; i < subdivisions.length; i++) { + const s = subdivisions[i]; + if (rootIndex >= s.startRootIndex && rootIndex < s.endRootIndex) { + return i; + } + } + return -1; +} + +/** + * Result of `splitPlacementsAtAnchor`. Used by the milestone-placement edit + * pipeline (add / promote) to atomically re-partition an existing milestone's + * subdivisions when a new milestone boundary is introduced inside it. + */ +export interface SplitPlacementsResult { + /** + * Placements that fall strictly before the new boundary. These remain on + * the original (now-shorter) milestone. + */ + before: MilestoneSubdivisionPlacement[]; + /** + * Placements that fall strictly after the new boundary, re-anchored as + * subdivisions of the freshly-created milestone. The placement at the + * boundary itself (if any) is NOT included here — it becomes the new + * milestone's implicit first subdivision and its name (if any) is + * surfaced via `boundaryName`. + */ + after: MilestoneSubdivisionPlacement[]; + /** + * If a placement existed exactly at the new boundary cell, its `name` is + * returned here. Callers persist this as the new milestone's + * `subdivisionNames["__start__"]` so the implicit first subdivision keeps + * the user's label even though it no longer corresponds to a placement. + */ + boundaryName?: string; +} + +/** + * Partitions a milestone's existing placements at an anchor cell when a new + * milestone is being inserted there (whether by direct add or by promotion of + * an existing subdivision break). + * + * `rootIds` is the ordered list of root content cell IDs in the original + * (un-split) milestone. `boundaryCellId` must appear in `rootIds`; otherwise + * the function returns the input unchanged in `before` (defensive). + * + * Placements whose `startCellId` is not a root cell are silently dropped — the + * resolver would prune them anyway. + */ +export function splitPlacementsAtAnchor( + placements: MilestoneSubdivisionPlacement[] | undefined, + rootIds: string[], + boundaryCellId: string +): SplitPlacementsResult { + const empty: SplitPlacementsResult = { before: [], after: [] }; + if (!boundaryCellId) return empty; + const boundaryIndex = rootIds.indexOf(boundaryCellId); + // Boundary outside the milestone or at the very start — caller should have + // rejected this earlier; we degrade gracefully to "no split" so we never + // silently lose data. + if (boundaryIndex <= 0) { + return { + before: Array.isArray(placements) ? [...placements] : [], + after: [], + }; + } + + const before: MilestoneSubdivisionPlacement[] = []; + const after: MilestoneSubdivisionPlacement[] = []; + let boundaryName: string | undefined; + const seen = new Set(); + + for (const placement of placements ?? []) { + if (!placement || typeof placement.startCellId !== "string") continue; + if (seen.has(placement.startCellId)) continue; + seen.add(placement.startCellId); + const idx = rootIds.indexOf(placement.startCellId); + if (idx === -1) continue; // stale anchor — drop + if (idx < boundaryIndex) { + const entry: MilestoneSubdivisionPlacement = { + startCellId: placement.startCellId, + }; + if (typeof placement.name === "string" && placement.name.length > 0) { + entry.name = placement.name; + } + before.push(entry); + } else if (idx === boundaryIndex) { + // Placement coincides with the new milestone boundary. We don't + // carry it into `after` because the new milestone's first + // subdivision is implicit; instead we surface its name so callers + // can stash it in `subdivisionNames[FIRST_SUBDIVISION_KEY]`. + if (typeof placement.name === "string" && placement.name.length > 0) { + boundaryName = placement.name; + } + } else { + const entry: MilestoneSubdivisionPlacement = { + startCellId: placement.startCellId, + }; + if (typeof placement.name === "string" && placement.name.length > 0) { + entry.name = placement.name; + } + after.push(entry); + } + } + + return { before, after, boundaryName }; +} + +/** + * Result of `mergePlacementsForRemovedMilestone`. Captures both the new + * placement list to write onto the surviving (previous) milestone, and the + * recommended source-side first-subdivision name carried over from the + * removed milestone (relevant only when `boundaryAnchorCellId` matches the + * surviving milestone's first root cell — see merge logic below). + */ +export interface MergePlacementsResult { + placements: MilestoneSubdivisionPlacement[]; +} + +/** + * Builds the merged placement list for the surviving (previous) milestone + * when a milestone is removed or demoted. Both operations expand the + * previous milestone's range to absorb the removed milestone's content + * cells; the difference is whether the boundary itself is preserved as a + * custom subdivision break. + * + * - `prevPlacements`: existing placements on the surviving milestone. + * - `removedPlacements`: placements on the milestone being removed (these + * are lifted up because their anchors still point at valid root cells in + * the merged milestone). + * - `boundaryAnchorCellId`: the first root content cell of the removed + * milestone. After the merge it sits at the seam between the two + * milestones' original cell ranges. + * - `boundaryName`: optional label for the boundary placement. When + * `preserveBoundary` is `true` and `boundaryName` is set, the boundary + * becomes a new placement on the surviving milestone (carrying that + * name) so the section heading isn't silently lost. + * - `preserveBoundary`: `true` for **demote** semantics (boundary kept as + * a subdivision break), `false` for **remove** semantics (boundary gone + * entirely). + * + * Placements are deduplicated by `startCellId` (last write wins on name). + */ +export function mergePlacementsForRemovedMilestone({ + prevPlacements, + removedPlacements, + boundaryAnchorCellId, + boundaryName, + preserveBoundary, +}: { + prevPlacements: MilestoneSubdivisionPlacement[] | undefined; + removedPlacements: MilestoneSubdivisionPlacement[] | undefined; + boundaryAnchorCellId?: string; + boundaryName?: string; + preserveBoundary: boolean; +}): MergePlacementsResult { + const merged = new Map(); + const push = (p: MilestoneSubdivisionPlacement | undefined) => { + if (!p || typeof p.startCellId !== "string") return; + const entry: MilestoneSubdivisionPlacement = { startCellId: p.startCellId }; + if (typeof p.name === "string" && p.name.length > 0) { + entry.name = p.name; + } + merged.set(p.startCellId, entry); + }; + + for (const p of prevPlacements ?? []) push(p); + + if ( + preserveBoundary && + typeof boundaryAnchorCellId === "string" && + boundaryAnchorCellId.length > 0 + ) { + // For demote: stamp the boundary as a fresh placement carrying the + // removed milestone's label as its name (when provided). Setting it + // before the removed milestone's other placements means the explicit + // boundary entry will be replaced if the removed milestone happened + // to also have a placement at that exact cell ID — that's fine; the + // removed-side name takes precedence as the more specific override. + const entry: MilestoneSubdivisionPlacement = { startCellId: boundaryAnchorCellId }; + if (typeof boundaryName === "string" && boundaryName.length > 0) { + entry.name = boundaryName; + } + merged.set(boundaryAnchorCellId, entry); + } + + for (const p of removedPlacements ?? []) push(p); + + return { placements: Array.from(merged.values()) }; +} diff --git a/src/test/suite/milestoneSubdivisions.test.ts b/src/test/suite/milestoneSubdivisions.test.ts new file mode 100644 index 000000000..b8aef9dca --- /dev/null +++ b/src/test/suite/milestoneSubdivisions.test.ts @@ -0,0 +1,2013 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import { CodexCellEditorProvider } from "../../providers/codexCellEditorProvider/codexCellEditorProvider"; +import { CodexCellDocument } from "../../providers/codexCellEditorProvider/codexDocument"; +import { CodexCellTypes } from "../../../types/enums"; +import { + FIRST_SUBDIVISION_KEY, + findSubdivisionIndexForRoot, + mergePlacementsForRemovedMilestone, + resolveSubdivisions, + splitPlacementsAtAnchor, +} from "../../providers/codexCellEditorProvider/utils/subdivisionUtils"; +import { __testOnlyMessageHandlers } from "../../providers/codexCellEditorProvider/codexCellEditorMessagehandling"; +import { + swallowDuplicateCommandRegistrations, + createTempCodexFile, + deleteIfExists, + createMockExtensionContext, +} from "../testUtils"; +import sinon from "sinon"; + +suite("Milestone Subdivisions Test Suite", () => { + vscode.window.showInformationMessage("Start all tests for Milestone Subdivisions."); + let context: vscode.ExtensionContext; + let provider: CodexCellEditorProvider; + let tempUri: vscode.Uri; + + suiteSetup(async () => { + swallowDuplicateCommandRegistrations(); + }); + + setup(async () => { + context = createMockExtensionContext(); + provider = new CodexCellEditorProvider(context); + tempUri = await createTempCodexFile( + `test-subdivisions-${Date.now()}-${Math.random().toString(36).slice(2)}.codex`, + { cells: [], metadata: {} } + ); + + sinon.restore(); + sinon.stub((CodexCellDocument as any).prototype, "addCellToIndexImmediately").callsFake(() => { }); + sinon.stub((CodexCellDocument as any).prototype, "syncDirtyCellsToDatabase").resolves(); + sinon.stub((CodexCellDocument as any).prototype, "populateSourceCellMapFromIndex").resolves(); + }); + + teardown(async () => { + if (tempUri) await deleteIfExists(tempUri); + sinon.restore(); + }); + + async function createDocumentWithCells(cells: any[]): Promise { + const content = { + cells, + metadata: {}, + }; + await vscode.workspace.fs.writeFile(tempUri, Buffer.from(JSON.stringify(content, null, 2), "utf-8")); + return await provider.openCustomDocument( + tempUri, + { backupId: undefined }, + new vscode.CancellationTokenSource().token + ); + } + + // --------------------------------------------------------------------------- + // Pure-function tests for resolveSubdivisions + // --------------------------------------------------------------------------- + + suite("resolveSubdivisions()", () => { + const ids = (count: number) => Array.from({ length: count }, (_, i) => `c${i}`); + + test("returns empty array when no root cells", () => { + const result = resolveSubdivisions({ + rootContentCellIds: [], + cellsPerPage: 50, + }); + assert.strictEqual(result.length, 0); + }); + + test("produces arithmetic chunks equivalent to legacy pagination when no placements", () => { + const rootIds = ids(125); + const result = resolveSubdivisions({ rootContentCellIds: rootIds, cellsPerPage: 50 }); + + assert.strictEqual(result.length, 3, "125 cells / 50 per page = 3 pages"); + assert.deepStrictEqual( + result.map((s) => [s.startRootIndex, s.endRootIndex]), + [[0, 50], [50, 100], [100, 125]], + "Legacy-equivalent arithmetic boundaries", + ); + assert.strictEqual(result[0].source, "auto"); + assert.strictEqual(result[0].key, FIRST_SUBDIVISION_KEY); + assert.strictEqual(result[1].startCellId, "c50"); + }); + + test("single page when count <= pageSize", () => { + const rootIds = ids(20); + const result = resolveSubdivisions({ rootContentCellIds: rootIds, cellsPerPage: 50 }); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].endRootIndex, 20); + }); + + test("custom placement creates two subdivisions (break at c6)", () => { + const rootIds = ids(10); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [{ startCellId: "c6", name: "Second Half" }], + cellsPerPage: 50, + }); + assert.strictEqual(result.length, 2); + assert.deepStrictEqual( + [result[0].startRootIndex, result[0].endRootIndex], + [0, 6], + ); + assert.deepStrictEqual( + [result[1].startRootIndex, result[1].endRootIndex], + [6, 10], + ); + assert.strictEqual(result[1].name, "Second Half"); + assert.strictEqual(result[1].source, "custom"); + assert.strictEqual(result[0].source, "custom", "first subdivision becomes custom once any break is defined"); + }); + + test("placements are sorted by root-index regardless of input order", () => { + const rootIds = ids(10); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [ + { startCellId: "c8" }, + { startCellId: "c3" }, + { startCellId: "c6" }, + ], + cellsPerPage: 50, + }); + assert.strictEqual(result.length, 4); + assert.deepStrictEqual( + result.map((s) => s.startRootIndex), + [0, 3, 6, 8], + ); + }); + + test("stale anchors (cells no longer present) are silently pruned", () => { + const rootIds = ids(5); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [ + { startCellId: "c3" }, + { startCellId: "doesNotExist" }, + { startCellId: "anotherMissing", name: "orphan" }, + ], + cellsPerPage: 50, + }); + assert.strictEqual(result.length, 2, "Only the valid placement contributes a break"); + assert.deepStrictEqual( + [result[0].startRootIndex, result[0].endRootIndex, result[1].startRootIndex, result[1].endRootIndex], + [0, 3, 3, 5], + ); + }); + + test("duplicate placements targeting the same cell are collapsed", () => { + const rootIds = ids(6); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [ + { startCellId: "c4" }, + { startCellId: "c4", name: "dup" }, + ], + cellsPerPage: 50, + }); + assert.strictEqual(result.length, 2); + }); + + test("placement at c0 names the implicit first subdivision rather than creating a new one", () => { + const rootIds = ids(4); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [{ startCellId: "c0", name: "Intro" }], + cellsPerPage: 50, + }); + assert.strictEqual(result.length, 1, "No actual break, just a name on the first subdivision"); + assert.strictEqual(result[0].name, "Intro"); + }); + + test("nameOverrides take precedence over source-stored names", () => { + const rootIds = ids(4); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [{ startCellId: "c3", name: "Source Name" }], + nameOverrides: { c3: "Target Override" }, + cellsPerPage: 50, + }); + assert.strictEqual(result[1].name, "Target Override"); + }); + + test("nameOverrides for first subdivision use FIRST_SUBDIVISION_KEY", () => { + const rootIds = ids(4); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [{ startCellId: "c3" }], + nameOverrides: { [FIRST_SUBDIVISION_KEY]: "My Start" }, + cellsPerPage: 50, + }); + assert.strictEqual(result[0].name, "My Start"); + }); + + test("findSubdivisionIndexForRoot locates the right subdivision", () => { + const rootIds = ids(10); + const subs = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [{ startCellId: "c3" }, { startCellId: "c7" }], + cellsPerPage: 50, + }); + assert.strictEqual(findSubdivisionIndexForRoot(subs, 0), 0); + assert.strictEqual(findSubdivisionIndexForRoot(subs, 3), 1); + assert.strictEqual(findSubdivisionIndexForRoot(subs, 7), 2); + assert.strictEqual(findSubdivisionIndexForRoot(subs, 99), -1); + }); + + // ------------------------------------------------------------------- + // Adaptive chunking: long stretches between user breaks get sub-paged + // ------------------------------------------------------------------- + + test("user break inside a long milestone still triggers per-page sub-chunking", () => { + // 700 roots, cellsPerPage=50, one custom break at root 432 (cell c432). + // Expected: [0,50), [50,100)…[400,432), then [432,482)…[682,700). + const rootIds = ids(700); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [{ startCellId: "c432" }], + cellsPerPage: 50, + }); + // Exactly one stretch boundary was added by the user, so the only "custom" + // entries should be the implicit-first stretch (now custom because a break + // exists) and the c432 anchor itself. Everything else is auto-derived. + const customStarts = result.filter((s) => s.source === "custom").map((s) => s.startRootIndex); + assert.deepStrictEqual(customStarts, [0, 432]); + // Sanity-check the boundaries on either side of the user break. + const indexAtBreak = result.findIndex((s) => s.startRootIndex === 432); + assert.notStrictEqual(indexAtBreak, -1, "expected a subdivision starting at root 432"); + const beforeBreak = result[indexAtBreak - 1]; + assert.strictEqual(beforeBreak.endRootIndex, 432, "stretch ending at the user break should not bleed past it"); + assert.strictEqual(beforeBreak.startRootIndex, 400, "auto chunks should respect cellsPerPage=50"); + // The trailing stretch should be paged from 432 in 50-cell increments. + const afterStarts = result + .slice(indexAtBreak) + .map((s) => s.startRootIndex); + assert.deepStrictEqual( + afterStarts.slice(0, 4), + [432, 482, 532, 582], + "trailing stretch should be sub-chunked starting at the user break" + ); + // And the very last subdivision should end at the milestone boundary, not past it. + assert.strictEqual(result[result.length - 1].endRootIndex, 700); + }); + + test("maxSubdivisionLength preserves stretches shorter than the threshold", () => { + // 200 roots, custom breaks at 70 and 173. cellsPerPage=70, threshold=120. + // Expected stretches: [0,70) len 70 → kept; [70,173) len 103 → kept (under 120); + // [173,200) len 27 → kept. So three subdivisions, all "custom" anchors. + const rootIds = ids(200); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [{ startCellId: "c70" }, { startCellId: "c173" }], + cellsPerPage: 70, + maxSubdivisionLength: 120, + }); + assert.strictEqual(result.length, 3); + assert.deepStrictEqual( + result.map((s) => [s.startRootIndex, s.endRootIndex]), + [[0, 70], [70, 173], [173, 200]] + ); + // All three are user-defined origins, so they should all be "custom". + assert.deepStrictEqual(result.map((s) => s.source), ["custom", "custom", "custom"]); + }); + + test("maxSubdivisionLength still sub-chunks stretches that exceed it", () => { + // 300 roots, no custom breaks, cellsPerPage=50, threshold=120. + // 300 > 120 so we should sub-chunk into 50-cell pages. + const rootIds = ids(300); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + cellsPerPage: 50, + maxSubdivisionLength: 120, + }); + assert.strictEqual(result.length, 6); + assert.deepStrictEqual( + result.map((s) => s.startRootIndex), + [0, 50, 100, 150, 200, 250] + ); + }); + + // ------------------------------------------------------------------- + // Source-name fallback: target inherits source's labels by default + // ------------------------------------------------------------------- + + test("fallbackNameOverrides supplies names when local override is missing", () => { + const rootIds = ids(20); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [{ startCellId: "c10" }], + fallbackNameOverrides: { + [FIRST_SUBDIVISION_KEY]: "Source Intro", + c10: "Source Conclusion", + }, + cellsPerPage: 50, + }); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].name, "Source Intro"); + assert.strictEqual(result[1].name, "Source Conclusion"); + }); + + test("local nameOverrides win over fallbackNameOverrides", () => { + const rootIds = ids(20); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [{ startCellId: "c10" }], + nameOverrides: { c10: "Target Conclusion" }, + fallbackNameOverrides: { + [FIRST_SUBDIVISION_KEY]: "Source Intro", + c10: "Source Conclusion", + }, + cellsPerPage: 50, + }); + // First subdivision: no local override → falls back to source. + assert.strictEqual(result[0].name, "Source Intro"); + // Second subdivision: local wins. + assert.strictEqual(result[1].name, "Target Conclusion"); + }); + + test("empty-string local override does NOT mask source fallback", () => { + // Important contract: clearing a name on the target should re-expose + // the inherited source name, not display "" / a numeric fallback. + const rootIds = ids(20); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [{ startCellId: "c10" }], + nameOverrides: { c10: "" }, + fallbackNameOverrides: { c10: "Source Conclusion" }, + cellsPerPage: 50, + }); + assert.strictEqual(result[1].name, "Source Conclusion"); + }); + }); + + // --------------------------------------------------------------------------- + // Document-level integration tests: subdivisions drive slicing APIs + // --------------------------------------------------------------------------- + + suite("CodexCellDocument slicing with custom subdivisions", () => { + /** Helper: build a milestone with 10 content cells and optional subdivisions. */ + function buildCellsWithSubdivisions(subdivisions?: Array<{ startCellId: string; name?: string; }>) { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: "Luke 1", + metadata: { + type: CodexCellTypes.MILESTONE, + id: "milestone-1", + ...(subdivisions + ? { + data: { + subdivisions, + }, + } + : {}), + }, + }, + ]; + for (let i = 1; i <= 10; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + return cells; + } + + test("buildMilestoneIndex attaches arithmetic subdivisions when none defined", async () => { + const document = await createDocumentWithCells(buildCellsWithSubdivisions()); + const index = document.buildMilestoneIndex(4); + const milestone = index.milestones[0]; + assert.ok(milestone.subdivisions, "subdivisions should be present"); + assert.strictEqual(milestone.subdivisions!.length, 3, "10 / 4 pageSize = 3 pages"); + assert.strictEqual(milestone.subdivisions![0].source, "auto"); + }); + + test("buildMilestoneIndex uses user-defined subdivisions when present", async () => { + const cells = buildCellsWithSubdivisions([ + { startCellId: "v4", name: "Middle" }, + { startCellId: "v8", name: "End" }, + ]); + const document = await createDocumentWithCells(cells); + const index = document.buildMilestoneIndex(50); + const milestone = index.milestones[0]; + assert.strictEqual(milestone.subdivisions!.length, 3); + assert.strictEqual(milestone.subdivisions![0].source, "custom"); + assert.strictEqual(milestone.subdivisions![1].name, "Middle"); + assert.strictEqual(milestone.subdivisions![2].name, "End"); + }); + + test("getCellsForMilestone slices by custom subdivision, not cellsPerPage", async () => { + const cells = buildCellsWithSubdivisions([{ startCellId: "v6" }]); + const document = await createDocumentWithCells(cells); + // Request subsection 0 with cellsPerPage=50 (larger than milestone). + // Without subdivisions this would return all 10 cells; with the break + // at v6, subsection 0 must contain only v1..v5. + const sub0 = document.getCellsForMilestone(0, 0, 50); + assert.strictEqual(sub0.length, 5); + assert.strictEqual(sub0[0].cellMarkers[0], "v1"); + assert.strictEqual(sub0[4].cellMarkers[0], "v5"); + + const sub1 = document.getCellsForMilestone(0, 1, 50); + assert.strictEqual(sub1.length, 5); + assert.strictEqual(sub1[0].cellMarkers[0], "v6"); + assert.strictEqual(sub1[4].cellMarkers[0], "v10"); + }); + + test("getSubsectionCountForMilestone reflects custom subdivisions", async () => { + const cells = buildCellsWithSubdivisions([ + { startCellId: "v4" }, + { startCellId: "v8" }, + ]); + const document = await createDocumentWithCells(cells); + const count = document.getSubsectionCountForMilestone(0, 50); + assert.strictEqual(count, 3, "Expected 3 subsections from 2 custom breaks"); + }); + + test("findMilestoneAndSubsectionForCell respects custom subdivisions", async () => { + const cells = buildCellsWithSubdivisions([ + { startCellId: "v4" }, + { startCellId: "v8" }, + ]); + const document = await createDocumentWithCells(cells); + const pos1 = document.findMilestoneAndSubsectionForCell("v2"); + assert.deepStrictEqual(pos1, { milestoneIndex: 0, subsectionIndex: 0 }); + const pos2 = document.findMilestoneAndSubsectionForCell("v5"); + assert.deepStrictEqual(pos2, { milestoneIndex: 0, subsectionIndex: 1 }); + const pos3 = document.findMilestoneAndSubsectionForCell("v9"); + assert.deepStrictEqual(pos3, { milestoneIndex: 0, subsectionIndex: 2 }); + }); + + test("stale anchor (referencing a cell since removed) is ignored at resolve time", async () => { + // v4 placement is valid; ghost placement should be silently skipped. + const cells = buildCellsWithSubdivisions([ + { startCellId: "v4" }, + { startCellId: "ghostCell" }, + ]); + const document = await createDocumentWithCells(cells); + const index = document.buildMilestoneIndex(50); + assert.strictEqual( + index.milestones[0].subdivisions!.length, + 2, + "Only the valid anchor should produce a break; ghost pruned", + ); + }); + + test("getRootContentCellIdsForMilestone returns all root content cells in order", async () => { + const cells = buildCellsWithSubdivisions(); + const document = await createDocumentWithCells(cells); + const rootIds = document.getRootContentCellIdsForMilestone(0); + assert.deepStrictEqual( + rootIds, + ["v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "v9", "v10"], + ); + }); + + test("getRootContentCellIdsForMilestone excludes paratext, deleted, and child cells", async () => { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: "Luke 1", + metadata: { type: CodexCellTypes.MILESTONE, id: "m1" }, + }, + { kind: 2, languageId: "scripture", value: "v1", metadata: { type: CodexCellTypes.TEXT, id: "v1" } }, + { + kind: 2, + languageId: "scripture", + value: "v1-child", + metadata: { type: CodexCellTypes.TEXT, id: "v1c", parentId: "v1" }, + }, + { + kind: 2, + languageId: "scripture", + value: "paratext", + metadata: { type: CodexCellTypes.PARATEXT, id: "p1" }, + }, + { + kind: 2, + languageId: "scripture", + value: "deleted", + metadata: { type: CodexCellTypes.TEXT, id: "d1", data: { deleted: true } }, + }, + { kind: 2, languageId: "scripture", value: "v2", metadata: { type: CodexCellTypes.TEXT, id: "v2" } }, + ]; + const document = await createDocumentWithCells(cells); + const rootIds = document.getRootContentCellIdsForMilestone(0); + assert.deepStrictEqual(rootIds, ["v1", "v2"]); + }); + + test("updateCellData('subdivisions') invalidates pagination cache", async () => { + const document = await createDocumentWithCells(buildCellsWithSubdivisions()); + + // First read: no custom breaks → arithmetic + const before = document.buildMilestoneIndex(50); + assert.strictEqual(before.milestones[0].subdivisions!.length, 1); + + // Update subdivisions via the same path the message handler uses. + document.updateCellData("milestone-1", { + subdivisions: [{ startCellId: "v6" }], + }); + + const after = document.buildMilestoneIndex(50); + assert.strictEqual( + after.milestones[0].subdivisions!.length, + 2, + "New subdivisions must be reflected after updateCellData", + ); + assert.strictEqual(after.milestones[0].subdivisions![1].startCellId, "v6"); + }); + + test("updateCellData('subdivisionNames') picks up name overrides", async () => { + const cells = buildCellsWithSubdivisions([{ startCellId: "v6" }]); + const document = await createDocumentWithCells(cells); + + document.updateCellData("milestone-1", { + subdivisionNames: { + [FIRST_SUBDIVISION_KEY]: "Opening", + v6: "Later Half", + }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones[0].subdivisions![0].name, "Opening"); + assert.strictEqual(index.milestones[0].subdivisions![1].name, "Later Half"); + }); + + test("empty subdivisions array restores arithmetic pagination", async () => { + const cells = buildCellsWithSubdivisions([{ startCellId: "v6" }]); + const document = await createDocumentWithCells(cells); + + // Sanity: starts custom + let index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones[0].subdivisions![0].source, "custom"); + + document.updateCellData("milestone-1", { subdivisions: [] }); + index = document.buildMilestoneIndex(50); + assert.strictEqual( + index.milestones[0].subdivisions![0].source, + "auto", + "Clearing placements should fall back to arithmetic pagination", + ); + }); + + // ----------------------------------------------------------------- + // addMilestoneSubdivisionAnchor handler — resolves cellNumber → cellId + // server-side and delegates to the shared commit pipeline. + // ----------------------------------------------------------------- + suite("addMilestoneSubdivisionAnchor handler", () => { + /** + * Mint a fake source URI so the handler's `isSourceFileFlexible` + * check passes without us needing to actually back the document + * with a .bible or .source file on disk. + */ + function stampSourceUri(document: CodexCellDocument) { + // `uri` is a plain public field (see CodexCellDocument), + // so a direct assignment is enough to override it. + (document as any).uri = vscode.Uri.parse("file:///test.source"); + } + + /** + * Stubs the provider touch-points the shared commit helper uses so + * the handler can run without a real webview round-trip: + * - `saveCustomDocument` becomes a no-op (we assert in-memory only) + * - `getPairedNotebookUri` returns null (no mirror step) + * - `refreshWebview` is swallowed + * - `currentMilestoneSubsectionMap` is empty, taking the simple + * refresh path in `sendMilestoneRefreshToWebview`. + */ + function stubProviderForHandlerTest(p: CodexCellEditorProvider) { + sinon.stub(p, "saveCustomDocument").resolves(); + sinon.stub(p, "getPairedNotebookUri").returns(null); + sinon.stub(p, "refreshWebview").resolves(); + (p as any).currentMilestoneSubsectionMap = new Map(); + // Author hook is a no-op for the integration path. + sinon.stub(CodexCellDocument.prototype as any, "refreshAuthor").resolves(); + } + + async function invokeAddAnchor({ + document, + milestoneIndex, + cellNumber, + }: { + document: CodexCellDocument; + milestoneIndex: number; + cellNumber: number; + }): Promise { + const handler = __testOnlyMessageHandlers["addMilestoneSubdivisionAnchor"]; + assert.ok(handler, "addMilestoneSubdivisionAnchor handler must be registered"); + await handler({ + event: { + command: "addMilestoneSubdivisionAnchor", + content: { milestoneIndex, cellNumber }, + } as any, + document, + webviewPanel: {} as any, + provider, + updateWebview: () => { /* no-op */ }, + }); + } + + test("adds a new anchor at the Nth root cell", async () => { + const cells = buildCellsWithSubdivisions(); + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeAddAnchor({ document, milestoneIndex: 0, cellNumber: 6 }); + + // The 6th root cell is v6 (array positions 0..9 → cell ids v1..v10). + const index = document.buildMilestoneIndex(50); + const subs = index.milestones[0].subdivisions ?? []; + assert.strictEqual(subs.length, 2, "Expected exactly one new break → two subsections"); + assert.strictEqual(subs[1].startCellId, "v6"); + assert.strictEqual(subs[1].source, "custom"); + }); + + test("appends anchor to existing placements (preserves source names)", async () => { + const cells = buildCellsWithSubdivisions([ + { startCellId: "v4", name: "Middle" }, + ]); + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeAddAnchor({ document, milestoneIndex: 0, cellNumber: 8 }); + + const index = document.buildMilestoneIndex(50); + const subs = index.milestones[0].subdivisions ?? []; + // Expect 3 subsections: v1..v3, v4..v7, v8..v10. + assert.strictEqual(subs.length, 3); + assert.strictEqual(subs[1].startCellId, "v4"); + assert.strictEqual(subs[1].name, "Middle", "Existing source-side name is preserved"); + assert.strictEqual(subs[2].startCellId, "v8"); + assert.strictEqual(subs[2].source, "custom"); + }); + + test("cellNumber at the first cell is rejected (no-op)", async () => { + const cells = buildCellsWithSubdivisions(); + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeAddAnchor({ document, milestoneIndex: 0, cellNumber: 1 }); + + const index = document.buildMilestoneIndex(50); + const subs = index.milestones[0].subdivisions ?? []; + // No breaks added — still the lone auto subdivision. + assert.strictEqual(subs.length, 1); + assert.strictEqual(subs[0].source, "auto"); + }); + + test("cellNumber beyond last cell is rejected", async () => { + const cells = buildCellsWithSubdivisions(); + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeAddAnchor({ document, milestoneIndex: 0, cellNumber: 99 }); + + const index = document.buildMilestoneIndex(50); + const subs = index.milestones[0].subdivisions ?? []; + assert.strictEqual(subs.length, 1, "Out-of-range cellNumber must not produce a break"); + }); + + test("idempotent: re-adding the same anchor does not duplicate", async () => { + const cells = buildCellsWithSubdivisions([{ startCellId: "v6" }]); + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + // v6 is already the 6th root cell; adding again should no-op. + await invokeAddAnchor({ document, milestoneIndex: 0, cellNumber: 6 }); + + const index = document.buildMilestoneIndex(50); + const subs = index.milestones[0].subdivisions ?? []; + assert.strictEqual(subs.length, 2, "Anchor set must remain the same size"); + const anchors = subs + .filter((s) => s.source === "custom" && s.index > 0) + .map((s) => s.startCellId); + assert.deepStrictEqual(anchors, ["v6"]); + }); + + test("rejects writes from non-source documents", async () => { + const cells = buildCellsWithSubdivisions(); + const document = await createDocumentWithCells(cells); + // Leave URI pointing at the temp .codex file → should be rejected. + stubProviderForHandlerTest(provider); + const warnStub = sinon.stub(vscode.window, "showWarningMessage"); + + await invokeAddAnchor({ document, milestoneIndex: 0, cellNumber: 5 }); + + assert.strictEqual( + warnStub.calledWith( + "Subdivision breaks can only be added from the source file." + ), + true, + "Non-source writes must surface a warning" + ); + const index = document.buildMilestoneIndex(50); + assert.strictEqual( + index.milestones[0].subdivisions?.length ?? 0, + 1, + "Non-source writes must not mutate the document" + ); + }); + }); + + // ----------------------------------------------------------------- + // updateMilestoneSubdivisionName handler — auto-chunk promotion + // ----------------------------------------------------------------- + // + // Naming an auto-generated break (on the source side) is treated as + // the translator committing to that break: it gets promoted from a + // derived arithmetic chunk to a persistent placement so downstream + // changes to `cellsPerPage` / `maxSubdivisionLength` don't displace + // or orphan the name. + suite("updateMilestoneSubdivisionName handler promotes auto-chunks", () => { + function stampSourceUri(document: CodexCellDocument) { + (document as any).uri = vscode.Uri.parse("file:///test.source"); + } + + function stubProviderForHandlerTest(p: CodexCellEditorProvider) { + sinon.stub(p, "saveCustomDocument").resolves(); + sinon.stub(p, "getPairedNotebookUri").returns(null); + sinon.stub(p, "refreshWebview").resolves(); + (p as any).currentMilestoneSubsectionMap = new Map(); + sinon.stub(CodexCellDocument.prototype as any, "refreshAuthor").resolves(); + } + + async function invokeRename({ + document, + milestoneIndex, + subdivisionKey, + newName, + }: { + document: CodexCellDocument; + milestoneIndex: number; + subdivisionKey: string; + newName: string; + }): Promise { + const handler = __testOnlyMessageHandlers["updateMilestoneSubdivisionName"]; + assert.ok(handler, "updateMilestoneSubdivisionName handler must be registered"); + await handler({ + event: { + command: "updateMilestoneSubdivisionName", + content: { milestoneIndex, subdivisionKey, newName }, + } as any, + document, + webviewPanel: {} as any, + provider, + updateWebview: () => { /* no-op */ }, + }); + } + + /** Builds a milestone with `rootCount` content cells + no placements. */ + function buildLargeMilestone(rootCount: number) { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: "Luke 1", + metadata: { type: CodexCellTypes.MILESTONE, id: "m1" }, + }, + ]; + for (let i = 1; i <= rootCount; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `v${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + return cells; + } + + test("naming an auto-chunk adds a persistent placement", async () => { + // 150 cells, cellsPerPage=50 → auto chunks at v1, v51, v101. + // Naming the v51 chunk should promote it to a real placement. + const document = await createDocumentWithCells(buildLargeMilestone(150)); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeRename({ + document, + milestoneIndex: 0, + subdivisionKey: "v51", + newName: "Section B", + }); + + const milestoneCell = document.getCellByIndex(0); + const data = milestoneCell?.metadata?.data as { + subdivisions?: { startCellId: string; name?: string; }[]; + subdivisionNames?: { [k: string]: string; }; + }; + assert.deepStrictEqual( + data.subdivisions, + [{ startCellId: "v51", name: "Section B" }], + "Renaming v51 should promote it into subdivisions[]" + ); + assert.strictEqual( + data.subdivisionNames?.["v51"], + "Section B", + "The subdivisionNames override should track in parallel" + ); + }); + + test("renaming an already-placed anchor syncs name on the placement", async () => { + // Start with a placement that has no name, then rename it. + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: "Luke 1", + metadata: { + type: CodexCellTypes.MILESTONE, + id: "m1", + data: { subdivisions: [{ startCellId: "v5" }] }, + }, + }, + ]; + for (let i = 1; i <= 10; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `v${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeRename({ + document, + milestoneIndex: 0, + subdivisionKey: "v5", + newName: "Second Half", + }); + + const milestoneCell = document.getCellByIndex(0); + const data = milestoneCell?.metadata?.data as { + subdivisions?: { startCellId: string; name?: string; }[]; + }; + assert.deepStrictEqual( + data.subdivisions, + [{ startCellId: "v5", name: "Second Half" }], + "Existing placement should have its name updated" + ); + }); + + test("clearing a name leaves the placement intact (demotion not implied)", async () => { + // Pre-promote v5 with a name, then clear it. Placement should + // remain so the user still sees a break there; only the name + // is removed. + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: "Luke 1", + metadata: { + type: CodexCellTypes.MILESTONE, + id: "m1", + data: { + subdivisions: [{ startCellId: "v5", name: "Named" }], + subdivisionNames: { v5: "Named" }, + }, + }, + }, + ]; + for (let i = 1; i <= 10; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `v${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeRename({ + document, + milestoneIndex: 0, + subdivisionKey: "v5", + newName: "", + }); + + const milestoneCell = document.getCellByIndex(0); + const data = milestoneCell?.metadata?.data as { + subdivisions?: { startCellId: string; name?: string; }[]; + subdivisionNames?: { [k: string]: string; }; + }; + assert.deepStrictEqual( + data.subdivisions, + [{ startCellId: "v5" }], + "Placement should remain; only the name is cleared" + ); + assert.strictEqual( + data.subdivisionNames?.["v5"], + undefined, + "The override map should no longer carry this key" + ); + }); + + test("naming the implicit first subdivision does NOT create a placement", async () => { + const document = await createDocumentWithCells(buildLargeMilestone(10)); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeRename({ + document, + milestoneIndex: 0, + subdivisionKey: FIRST_SUBDIVISION_KEY, + newName: "Intro", + }); + + const milestoneCell = document.getCellByIndex(0); + const data = milestoneCell?.metadata?.data as { + subdivisions?: { startCellId: string; name?: string; }[]; + subdivisionNames?: { [k: string]: string; }; + }; + assert.ok( + !data.subdivisions || data.subdivisions.length === 0, + "First subdivision has no placement; must not be promoted" + ); + assert.strictEqual(data.subdivisionNames?.[FIRST_SUBDIVISION_KEY], "Intro"); + }); + + test("clearing a name on an unplaced auto-chunk does not create a placement", async () => { + // Edge: user opens the rename field, types nothing, then + // blurs. We should not invent a placement just because they + // touched the control. + const document = await createDocumentWithCells(buildLargeMilestone(150)); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeRename({ + document, + milestoneIndex: 0, + subdivisionKey: "v51", + newName: "", + }); + + const milestoneCell = document.getCellByIndex(0); + const data = milestoneCell?.metadata?.data as { + subdivisions?: { startCellId: string; name?: string; }[]; + }; + assert.ok( + !data.subdivisions || data.subdivisions.length === 0, + "Empty rename on an auto-chunk must not promote it" + ); + }); + + test("target-side rename never adds a placement", async () => { + // Target docs are pointed at .codex (not .source), so the + // handler's promotion branch must not run. Verify by leaving + // the temp .codex URI in place. + const document = await createDocumentWithCells(buildLargeMilestone(150)); + // Intentionally skip stampSourceUri. + stubProviderForHandlerTest(provider); + + await invokeRename({ + document, + milestoneIndex: 0, + subdivisionKey: "v51", + newName: "Target Name", + }); + + const milestoneCell = document.getCellByIndex(0); + const data = milestoneCell?.metadata?.data as { + subdivisions?: { startCellId: string; name?: string; }[]; + subdivisionNames?: { [k: string]: string; }; + }; + assert.ok( + !data.subdivisions || data.subdivisions.length === 0, + "Target-side renames must not introduce placements (source-authoritative)" + ); + assert.strictEqual( + data.subdivisionNames?.["v51"], + "Target Name", + "Target rename still lands in local subdivisionNames override" + ); + }); + }); + + test("legacy behavior preserved when no subdivisions on milestone", async () => { + // Sanity check: 125 cells with cellsPerPage=50 → 3 subsections and each page + // sized exactly as before the refactor. + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: "Luke 1", + metadata: { type: CodexCellTypes.MILESTONE, id: "m1" }, + }, + ]; + for (let i = 1; i <= 125; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `v${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + const document = await createDocumentWithCells(cells); + assert.strictEqual(document.getSubsectionCountForMilestone(0, 50), 3); + assert.strictEqual(document.getCellsForMilestone(0, 0, 50).length, 50); + assert.strictEqual(document.getCellsForMilestone(0, 1, 50).length, 50); + assert.strictEqual(document.getCellsForMilestone(0, 2, 50).length, 25); + }); + }); + + // --------------------------------------------------------------------------- + // Pure-function tests for the milestone-placement edit redistribution helpers + // --------------------------------------------------------------------------- + + suite("splitPlacementsAtAnchor()", () => { + const rootIds = ["v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "v9", "v10"]; + + test("partitions placements strictly before / after the anchor", () => { + const result = splitPlacementsAtAnchor( + [ + { startCellId: "v3", name: "Pre" }, + { startCellId: "v8", name: "Post" }, + ], + rootIds, + "v6" + ); + assert.deepStrictEqual(result.before, [{ startCellId: "v3", name: "Pre" }]); + assert.deepStrictEqual(result.after, [{ startCellId: "v8", name: "Post" }]); + assert.strictEqual(result.boundaryName, undefined); + }); + + test("placement at the anchor surfaces as boundaryName, not as a placement", () => { + const result = splitPlacementsAtAnchor( + [ + { startCellId: "v3" }, + { startCellId: "v6", name: "Mid Section" }, + { startCellId: "v9" }, + ], + rootIds, + "v6" + ); + assert.deepStrictEqual(result.before, [{ startCellId: "v3" }]); + assert.deepStrictEqual(result.after, [{ startCellId: "v9" }]); + assert.strictEqual(result.boundaryName, "Mid Section"); + }); + + test("stale anchors are silently dropped", () => { + const result = splitPlacementsAtAnchor( + [ + { startCellId: "v3" }, + { startCellId: "ghost" }, + { startCellId: "v8" }, + ], + rootIds, + "v6" + ); + assert.strictEqual(result.before.length, 1); + assert.strictEqual(result.after.length, 1); + }); + + test("anchor at index 0 returns input unchanged in `before`", () => { + const result = splitPlacementsAtAnchor( + [{ startCellId: "v3" }], + rootIds, + "v1" + ); + assert.deepStrictEqual(result.before, [{ startCellId: "v3" }]); + assert.strictEqual(result.after.length, 0); + }); + + test("anchor not in rootIds returns empty result", () => { + const result = splitPlacementsAtAnchor( + [{ startCellId: "v3" }], + rootIds, + "ghost" + ); + assert.deepStrictEqual(result, { + before: [{ startCellId: "v3" }], + after: [], + }); + }); + + test("undefined placements yield empty partitions", () => { + const result = splitPlacementsAtAnchor(undefined, rootIds, "v5"); + assert.deepStrictEqual(result.before, []); + assert.deepStrictEqual(result.after, []); + }); + + test("duplicate placements are deduped (first wins)", () => { + const result = splitPlacementsAtAnchor( + [ + { startCellId: "v3", name: "First" }, + { startCellId: "v3", name: "Second" }, + { startCellId: "v8" }, + ], + rootIds, + "v6" + ); + assert.strictEqual(result.before.length, 1); + assert.strictEqual(result.before[0].name, "First"); + }); + }); + + suite("mergePlacementsForRemovedMilestone()", () => { + test("preserveBoundary=true stamps boundary placement with the milestone label", () => { + const result = mergePlacementsForRemovedMilestone({ + prevPlacements: [{ startCellId: "v3" }], + removedPlacements: [{ startCellId: "v9" }], + boundaryAnchorCellId: "v7", + boundaryName: "Luke 2", + preserveBoundary: true, + }); + assert.deepStrictEqual(result.placements, [ + { startCellId: "v3" }, + { startCellId: "v7", name: "Luke 2" }, + { startCellId: "v9" }, + ]); + }); + + test("preserveBoundary=false omits the boundary placement entirely", () => { + const result = mergePlacementsForRemovedMilestone({ + prevPlacements: [{ startCellId: "v3" }], + removedPlacements: [{ startCellId: "v9", name: "Tail" }], + boundaryAnchorCellId: "v7", + boundaryName: "Luke 2", + preserveBoundary: false, + }); + assert.deepStrictEqual(result.placements, [ + { startCellId: "v3" }, + { startCellId: "v9", name: "Tail" }, + ]); + }); + + test("removed-side name on boundary cell wins over preserveBoundary's milestone label", () => { + // When the demoted milestone already had an explicit name on its + // own first cell (in `subdivisions[]`), that name takes precedence + // because it's more specific than the milestone label. + const result = mergePlacementsForRemovedMilestone({ + prevPlacements: [], + removedPlacements: [ + { startCellId: "v7", name: "Specific Name" }, + ], + boundaryAnchorCellId: "v7", + boundaryName: "Luke 2", + preserveBoundary: true, + }); + assert.strictEqual(result.placements.length, 1); + assert.strictEqual(result.placements[0].name, "Specific Name"); + }); + + test("dedupes by startCellId across both sources", () => { + const result = mergePlacementsForRemovedMilestone({ + prevPlacements: [{ startCellId: "v3", name: "A" }], + removedPlacements: [{ startCellId: "v3", name: "B" }], + boundaryAnchorCellId: "v7", + preserveBoundary: false, + }); + assert.strictEqual(result.placements.length, 1); + assert.strictEqual(result.placements[0].name, "B", "removed-side overwrites prev for same key"); + }); + }); + + // --------------------------------------------------------------------------- + // Handler integration tests for the milestone-placement edit pipeline. + // --------------------------------------------------------------------------- + + suite("Milestone placement edit handlers", () => { + function stampSourceUri(document: CodexCellDocument) { + (document as any).uri = vscode.Uri.parse("file:///test.source"); + } + + function stubProviderForHandlerTest(p: CodexCellEditorProvider) { + sinon.stub(p, "saveCustomDocument").resolves(); + sinon.stub(p, "getPairedNotebookUri").returns(null); + sinon.stub(p, "refreshWebview").resolves(); + (p as any).currentMilestoneSubsectionMap = new Map(); + sinon.stub(CodexCellDocument.prototype as any, "refreshAuthor").resolves(); + // Avoid SQLite-mirror writes during structural-edit tests; the + // milestone-index reflush isn't observable in the in-memory + // assertions below and the stubs above already disable database + // I/O. + sinon.stub( + CodexCellDocument.prototype as any, + "updateCellMilestoneIndices" + ).resolves(); + } + + /** Builds a single-milestone document with N text cells (v1..vN). */ + function buildMilestoneWithRoots(rootCount: number, milestoneId = "m1", milestoneValue = "Luke 1") { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: milestoneValue, + metadata: { type: CodexCellTypes.MILESTONE, id: milestoneId }, + }, + ]; + for (let i = 1; i <= rootCount; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + return cells; + } + + /** Builds a doc with two milestones, each with their own root cells. */ + function buildTwoMilestoneDoc() { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: "Luke 1", + metadata: { type: CodexCellTypes.MILESTONE, id: "m1" }, + }, + ]; + for (let i = 1; i <= 5; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + cells.push({ + kind: 2, + languageId: "scripture", + value: "Luke 2", + metadata: { type: CodexCellTypes.MILESTONE, id: "m2" }, + }); + for (let i = 6; i <= 10; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + return cells; + } + + async function invokeHandler( + handlerName: string, + { document, content }: { document: CodexCellDocument; content: any; } + ): Promise { + const handler = __testOnlyMessageHandlers[handlerName]; + assert.ok(handler, `${handlerName} handler must be registered`); + await handler({ + event: { command: handlerName, content } as any, + document, + webviewPanel: {} as any, + provider, + updateWebview: () => { /* no-op */ }, + }); + } + + test("addMilestoneAtCell inserts a milestone and partitions placements", async () => { + const cells = buildMilestoneWithRoots(10); + // Pre-existing custom subdivisions: one before the new boundary + // (should stay on the original) and one after (should travel). + cells[0].metadata.data = { + subdivisions: [ + { startCellId: "v3", name: "Early" }, + { startCellId: "v8", name: "Late" }, + ], + }; + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeHandler("addMilestoneAtCell", { + document, + content: { milestoneIndex: 0, cellNumber: 6 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 2, "Should now have two milestones"); + // Original milestone: only v1..v5, with v3 placement preserved + assert.deepStrictEqual( + document.getRootContentCellIdsForMilestone(0), + ["v1", "v2", "v3", "v4", "v5"] + ); + const original = document.getCellByIndex(index.milestones[0].cellIndex); + const originalData = original?.metadata?.data as any; + assert.deepStrictEqual( + originalData?.subdivisions, + [{ startCellId: "v3", name: "Early" }] + ); + // New milestone: v6..v10, with v8 placement preserved + assert.deepStrictEqual( + document.getRootContentCellIdsForMilestone(1), + ["v6", "v7", "v8", "v9", "v10"] + ); + const inserted = document.getCellByIndex(index.milestones[1].cellIndex); + const insertedData = inserted?.metadata?.data as any; + assert.deepStrictEqual( + insertedData?.subdivisions, + [{ startCellId: "v8", name: "Late" }] + ); + assert.strictEqual( + inserted?.metadata?.type, + CodexCellTypes.MILESTONE + ); + }); + + test("addMilestoneAtCell rejects cellNumber < 2 as a no-op", async () => { + const document = await createDocumentWithCells(buildMilestoneWithRoots(10)); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + sinon.stub(vscode.window, "showWarningMessage"); + + await invokeHandler("addMilestoneAtCell", { + document, + content: { milestoneIndex: 0, cellNumber: 1 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 1, "No new milestone should be created"); + }); + + test("addMilestoneAtCell rejects writes from non-source documents", async () => { + const document = await createDocumentWithCells(buildMilestoneWithRoots(10)); + // Leave URI as the temp .codex file → handler must reject. + stubProviderForHandlerTest(provider); + const warnStub = sinon.stub(vscode.window, "showWarningMessage"); + + await invokeHandler("addMilestoneAtCell", { + document, + content: { milestoneIndex: 0, cellNumber: 6 }, + }); + + assert.strictEqual(warnStub.called, true, "Should surface a warning on target write"); + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 1, "Document must not be mutated"); + }); + + test("promoteSubdivisionToMilestone uses the subdivision's name as the new milestone label", async () => { + const cells = buildMilestoneWithRoots(10); + cells[0].metadata.data = { + subdivisions: [{ startCellId: "v6", name: "Section B" }], + }; + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeHandler("promoteSubdivisionToMilestone", { + document, + content: { milestoneIndex: 0, subdivisionKey: "v6" }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 2); + const inserted = document.getCellByIndex(index.milestones[1].cellIndex); + assert.strictEqual(inserted?.value, "Section B", "Promoted milestone takes the subdivision's name"); + // The promoted placement should NOT remain on the original + // milestone — it has been promoted. + const original = document.getCellByIndex(index.milestones[0].cellIndex); + const originalData = original?.metadata?.data as any; + assert.ok( + !originalData?.subdivisions || originalData.subdivisions.length === 0, + "Promoted placement should be removed from the original milestone" + ); + }); + + test("promoteSubdivisionToMilestone refuses the implicit first subdivision", async () => { + const document = await createDocumentWithCells(buildMilestoneWithRoots(10)); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + sinon.stub(vscode.window, "showWarningMessage"); + + await invokeHandler("promoteSubdivisionToMilestone", { + document, + content: { + milestoneIndex: 0, + subdivisionKey: FIRST_SUBDIVISION_KEY, + }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 1, "First subdivision is unpromotable"); + }); + + test("promoteSubdivisionToMilestone falls back to 'New milestone' when the subdivision is unnamed", async () => { + const cells = buildMilestoneWithRoots(10); + // Custom subdivision at v6 with NO `name` — i.e. just a break. + cells[0].metadata.data = { + subdivisions: [{ startCellId: "v6" }], + }; + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeHandler("promoteSubdivisionToMilestone", { + document, + content: { milestoneIndex: 0, subdivisionKey: "v6" }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 2); + const inserted = document.getCellByIndex(index.milestones[1].cellIndex); + assert.strictEqual( + inserted?.value, + "New milestone", + "Unnamed promotions should not duplicate the parent milestone's chapter label" + ); + }); + + test("addMilestoneAtCell stamps 'New milestone' as the placeholder label", async () => { + const document = await createDocumentWithCells(buildMilestoneWithRoots(10)); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeHandler("addMilestoneAtCell", { + document, + content: { milestoneIndex: 0, cellNumber: 6 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 2); + const inserted = document.getCellByIndex(index.milestones[1].cellIndex); + assert.strictEqual( + inserted?.value, + "New milestone", + "New milestones should default to a placeholder, not the parent chapter label" + ); + }); + + test("removeMilestone soft-deletes the milestone and lifts its subdivisions", async () => { + const cells = buildTwoMilestoneDoc(); + // Give the second milestone a custom subdivision on v8. + cells[6].metadata.data = { + subdivisions: [{ startCellId: "v8", name: "Tail" }], + }; + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeHandler("removeMilestone", { + document, + content: { milestoneIndex: 1 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 1, "Only the first milestone should remain"); + // Surviving milestone must absorb v6..v10 alongside v1..v5. + const survivor = document.getCellByIndex(index.milestones[0].cellIndex); + const data = survivor?.metadata?.data as any; + assert.deepStrictEqual( + data?.subdivisions, + [{ startCellId: "v8", name: "Tail" }], + "Removed milestone's placements lift onto the survivor" + ); + // No boundary placement at v6 — pure remove drops the seam. + assert.ok( + !data?.subdivisions?.some((p: any) => p.startCellId === "v6"), + "Pure remove should not preserve the boundary as a subdivision" + ); + }); + + test("removeMilestone refuses to remove the first milestone", async () => { + const document = await createDocumentWithCells(buildTwoMilestoneDoc()); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + sinon.stub(vscode.window, "showWarningMessage"); + + await invokeHandler("removeMilestone", { + document, + content: { milestoneIndex: 0 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 2, "First milestone must not be removable"); + }); + + test("demoteMilestoneToSubdivision preserves the boundary and carries the milestone label", async () => { + const document = await createDocumentWithCells(buildTwoMilestoneDoc()); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeHandler("demoteMilestoneToSubdivision", { + document, + content: { milestoneIndex: 1 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 1); + const survivor = document.getCellByIndex(index.milestones[0].cellIndex); + const data = survivor?.metadata?.data as any; + assert.deepStrictEqual( + data?.subdivisions, + [{ startCellId: "v6", name: "Luke 2" }], + "Demoted milestone's label becomes the boundary placement's name" + ); + }); + + test("demoteMilestoneToSubdivision refuses to demote the first milestone", async () => { + const document = await createDocumentWithCells(buildTwoMilestoneDoc()); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + sinon.stub(vscode.window, "showWarningMessage"); + + await invokeHandler("demoteMilestoneToSubdivision", { + document, + content: { milestoneIndex: 0 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 2, "First milestone must not be demotable"); + }); + }); + + // --------------------------------------------------------------------------- + // Milestone rename mirror: renaming a milestone on the source should + // overwrite the paired target's `cell.value` as-is (no override pattern). + // Renaming on the target should leave the source untouched. + // --------------------------------------------------------------------------- + + suite("Milestone rename mirror (source -> target)", () => { + let pairedTempUri: vscode.Uri | undefined; + + teardown(async () => { + if (pairedTempUri) { + await deleteIfExists(pairedTempUri); + pairedTempUri = undefined; + } + }); + + function buildSingleMilestoneCells(milestoneId: string, milestoneValue: string) { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: milestoneValue, + metadata: { type: CodexCellTypes.MILESTONE, id: milestoneId }, + }, + ]; + for (let i = 1; i <= 3; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + return cells; + } + + async function createPairedTargetDocument(cells: any[]): Promise<{ + uri: vscode.Uri; + document: CodexCellDocument; + }> { + const targetUri = await createTempCodexFile( + `test-target-${Date.now()}-${Math.random().toString(36).slice(2)}.codex`, + { cells, metadata: {} } + ); + pairedTempUri = targetUri; + const targetDocument = await provider.openCustomDocument( + targetUri, + { backupId: undefined }, + new vscode.CancellationTokenSource().token + ); + return { uri: targetUri, document: targetDocument }; + } + + function stampSourceUri(document: CodexCellDocument) { + (document as any).uri = vscode.Uri.parse("file:///test.source"); + } + + async function invokeHandler( + handlerName: string, + { document, content }: { document: CodexCellDocument; content: any; } + ): Promise { + const handler = __testOnlyMessageHandlers[handlerName]; + assert.ok(handler, `${handlerName} handler must be registered`); + await handler({ + event: { command: handlerName, content } as any, + document, + webviewPanel: {} as any, + provider, + updateWebview: () => { /* no-op */ }, + }); + } + + function stubProviderForRenameTest(p: CodexCellEditorProvider, targetDoc?: CodexCellDocument, targetUri?: vscode.Uri) { + sinon.stub(p, "saveCustomDocument").resolves(); + sinon.stub(p, "refreshWebview").resolves(); + sinon.stub(p, "getWebviewPanelForUri").returns(undefined); + (p as any).currentMilestoneSubsectionMap = new Map(); + sinon.stub(CodexCellDocument.prototype as any, "refreshAuthor").resolves(); + sinon.stub( + CodexCellDocument.prototype as any, + "updateCellMilestoneIndices" + ).resolves(); + if (targetDoc && targetUri) { + sinon.stub(p, "getPairedNotebookUri").returns(targetUri); + sinon.stub(p, "getOrOpenDocumentForUri").resolves(targetDoc); + } else { + sinon.stub(p, "getPairedNotebookUri").returns(null); + } + } + + test("source rename overwrites the paired target's milestone value", async () => { + const sourceCells = buildSingleMilestoneCells("ms-1", "Luke 1"); + const targetCells = buildSingleMilestoneCells("ms-1", "Luke 1"); + const sourceDoc = await createDocumentWithCells(sourceCells); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument(targetCells); + stubProviderForRenameTest(provider, targetDoc, targetUri); + + await invokeHandler("updateMilestoneValue", { + document: sourceDoc, + content: { milestoneIndex: 0, newValue: "Section A" }, + }); + + const sourceIndex = sourceDoc.buildMilestoneIndex(50); + const sourceCell = sourceDoc.getCellByIndex(sourceIndex.milestones[0].cellIndex); + assert.strictEqual(sourceCell?.value, "Section A", "Source should be renamed"); + + const targetIndex = targetDoc.buildMilestoneIndex(50); + const targetCell = targetDoc.getCellByIndex(targetIndex.milestones[0].cellIndex); + assert.strictEqual( + targetCell?.value, + "Section A", + "Target should mirror the source's new label as-is" + ); + }); + + test("source rename overwrites a target-side rename (no override stickiness)", async () => { + // Target user has already renamed the milestone locally to "Lucas 1". + // The user's clarification was: "just bring it over and name it as-is. + // The user can change it later." → next source rename wins. + const sourceCells = buildSingleMilestoneCells("ms-1", "Luke 1"); + const targetCells = buildSingleMilestoneCells("ms-1", "Lucas 1"); + const sourceDoc = await createDocumentWithCells(sourceCells); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument(targetCells); + stubProviderForRenameTest(provider, targetDoc, targetUri); + + await invokeHandler("updateMilestoneValue", { + document: sourceDoc, + content: { milestoneIndex: 0, newValue: "Section A" }, + }); + + const targetIndex = targetDoc.buildMilestoneIndex(50); + const targetCell = targetDoc.getCellByIndex(targetIndex.milestones[0].cellIndex); + assert.strictEqual( + targetCell?.value, + "Section A", + "Source rename overwrites target's local label" + ); + }); + + test("target rename does NOT mirror back to the source", async () => { + // The target document is being renamed directly. Source must stay + // untouched — placements remain source-authoritative. + const sourceCells = buildSingleMilestoneCells("ms-1", "Luke 1"); + const targetCells = buildSingleMilestoneCells("ms-1", "Luke 1"); + const sourceDoc = await createDocumentWithCells(sourceCells); + // Leave sourceDoc URI as the temp .codex; we only need it to be a + // separate doc to verify it isn't touched. + const { document: targetDoc } = await createPairedTargetDocument(targetCells); + // Intentionally do NOT pass sourceDoc as paired; the handler should + // detect it's a target rename via `isSourceFileFlexible` and skip + // the mirror entirely. + stubProviderForRenameTest(provider); + + await invokeHandler("updateMilestoneValue", { + document: targetDoc, + content: { milestoneIndex: 0, newValue: "Lucas 1" }, + }); + + const targetIndex = targetDoc.buildMilestoneIndex(50); + const targetCell = targetDoc.getCellByIndex(targetIndex.milestones[0].cellIndex); + assert.strictEqual(targetCell?.value, "Lucas 1", "Target rename applies locally"); + + const sourceIndex = sourceDoc.buildMilestoneIndex(50); + const sourceCell = sourceDoc.getCellByIndex(sourceIndex.milestones[0].cellIndex); + assert.strictEqual( + sourceCell?.value, + "Luke 1", + "Source must not be mutated by a target-side rename" + ); + }); + + test("mirror is skipped when the target's milestone cell ID has diverged", async () => { + // Same root cell IDs, but the milestone cell itself has a different + // ID on the target — e.g. the project drifted apart structurally. + // We must NOT overwrite an unrelated milestone's label. + const sourceCells = buildSingleMilestoneCells("ms-source-1", "Luke 1"); + const targetCells = buildSingleMilestoneCells("ms-target-1", "Luke 1"); + const sourceDoc = await createDocumentWithCells(sourceCells); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument(targetCells); + stubProviderForRenameTest(provider, targetDoc, targetUri); + + await invokeHandler("updateMilestoneValue", { + document: sourceDoc, + content: { milestoneIndex: 0, newValue: "Section A" }, + }); + + const targetIndex = targetDoc.buildMilestoneIndex(50); + const targetCell = targetDoc.getCellByIndex(targetIndex.milestones[0].cellIndex); + assert.strictEqual( + targetCell?.value, + "Luke 1", + "Target must not be touched when milestone IDs diverge" + ); + }); + }); + + // --------------------------------------------------------------------------- + // Milestone structural mirror: promote / demote / remove must mirror the + // structural change onto the paired target document. Regression target: + // the pre-fix code compared the source's post-mutation root ids against + // the (still unmutated) target inside `commitMergeMilestoneIntoPrevious`, + // so the divergence guard fired on every merge and the target silently + // stayed in its pre-edit state. + // --------------------------------------------------------------------------- + + suite("Milestone structural mirror (source -> target)", () => { + let pairedTempUri: vscode.Uri | undefined; + + teardown(async () => { + if (pairedTempUri) { + await deleteIfExists(pairedTempUri); + pairedTempUri = undefined; + } + }); + + function buildTwoMilestoneCells() { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: "Luke 1", + metadata: { type: CodexCellTypes.MILESTONE, id: "m1" }, + }, + ]; + for (let i = 1; i <= 5; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + cells.push({ + kind: 2, + languageId: "scripture", + value: "Luke 2", + metadata: { type: CodexCellTypes.MILESTONE, id: "m2" }, + }); + for (let i = 6; i <= 10; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + return cells; + } + + async function createPairedTargetDocument(cells: any[]): Promise<{ + uri: vscode.Uri; + document: CodexCellDocument; + }> { + const targetUri = await createTempCodexFile( + `test-target-${Date.now()}-${Math.random().toString(36).slice(2)}.codex`, + { cells, metadata: {} } + ); + pairedTempUri = targetUri; + const targetDocument = await provider.openCustomDocument( + targetUri, + { backupId: undefined }, + new vscode.CancellationTokenSource().token + ); + return { uri: targetUri, document: targetDocument }; + } + + function stampSourceUri(document: CodexCellDocument) { + (document as any).uri = vscode.Uri.parse("file:///test.source"); + } + + function stubProviderForStructuralMirror( + p: CodexCellEditorProvider, + targetDoc: CodexCellDocument, + targetUri: vscode.Uri + ) { + sinon.stub(p, "saveCustomDocument").resolves(); + sinon.stub(p, "refreshWebview").resolves(); + sinon.stub(p, "getWebviewPanelForUri").returns(undefined); + (p as any).currentMilestoneSubsectionMap = new Map(); + sinon.stub(CodexCellDocument.prototype as any, "refreshAuthor").resolves(); + sinon.stub( + CodexCellDocument.prototype as any, + "updateCellMilestoneIndices" + ).resolves(); + sinon.stub(p, "getPairedNotebookUri").returns(targetUri); + sinon.stub(p, "getOrOpenDocumentForUri").resolves(targetDoc); + } + + async function invokeHandler( + handlerName: string, + { document, content }: { document: CodexCellDocument; content: any; } + ): Promise { + const handler = __testOnlyMessageHandlers[handlerName]; + assert.ok(handler, `${handlerName} handler must be registered`); + await handler({ + event: { command: handlerName, content } as any, + document, + webviewPanel: {} as any, + provider, + updateWebview: () => { /* no-op */ }, + }); + } + + test("removeMilestone mirrors the structural delete onto the target", async () => { + const sourceDoc = await createDocumentWithCells(buildTwoMilestoneCells()); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument( + buildTwoMilestoneCells() + ); + stubProviderForStructuralMirror(provider, targetDoc, targetUri); + + const targetBefore = targetDoc.buildMilestoneIndex(50); + assert.strictEqual( + targetBefore.milestones.length, + 2, + "Target should start with two milestones" + ); + + await invokeHandler("removeMilestone", { + document: sourceDoc, + content: { milestoneIndex: 1 }, + }); + + const sourceAfter = sourceDoc.buildMilestoneIndex(50); + assert.strictEqual(sourceAfter.milestones.length, 1, "Source should be merged"); + + // Regression: pre-fix the merge silently skipped the mirror because + // the divergence check compared the source's POST-mutation roots + // (v1..v10) against the target's pre-mutation roots (v1..v5), so + // the rootsMatchPrev check always failed. + const targetAfter = targetDoc.buildMilestoneIndex(50); + assert.strictEqual( + targetAfter.milestones.length, + 1, + "Target should mirror the structural delete and end up with one milestone" + ); + }); + + test("demoteMilestoneToSubdivision mirrors the merge + boundary onto the target", async () => { + const sourceDoc = await createDocumentWithCells(buildTwoMilestoneCells()); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument( + buildTwoMilestoneCells() + ); + stubProviderForStructuralMirror(provider, targetDoc, targetUri); + + await invokeHandler("demoteMilestoneToSubdivision", { + document: sourceDoc, + content: { milestoneIndex: 1 }, + }); + + const targetAfter = targetDoc.buildMilestoneIndex(50); + assert.strictEqual( + targetAfter.milestones.length, + 1, + "Target should mirror the demote merge" + ); + + const survivor = targetDoc.getCellByIndex(targetAfter.milestones[0].cellIndex); + const data = survivor?.metadata?.data as any; + assert.deepStrictEqual( + data?.subdivisions, + [{ startCellId: "v6", name: "Luke 2" }], + "Target's surviving milestone keeps the demoted seam as a subdivision break with the mirrored label" + ); + assert.deepStrictEqual( + data?.subdivisionNamesFromSource ?? {}, + {}, + "Preserved demoted label is stored on subdivisions[].name, not duplicated in subdivisionNamesFromSource" + ); + }); + + test("demote folds target translator subdivisionNames from the removed milestone onto the survivor", async () => { + const sourceCells = buildTwoMilestoneCells(); + const targetCells = buildTwoMilestoneCells(); + targetCells[6].metadata.data = { + ...(targetCells[6].metadata.data ?? {}), + subdivisionNames: { v8: "Tail EN" }, + }; + + const sourceDoc = await createDocumentWithCells(sourceCells); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument(targetCells); + stubProviderForStructuralMirror(provider, targetDoc, targetUri); + + await invokeHandler("demoteMilestoneToSubdivision", { + document: sourceDoc, + content: { milestoneIndex: 1 }, + }); + + const targetAfter = targetDoc.buildMilestoneIndex(50); + const survivor = targetDoc.getCellByIndex(targetAfter.milestones[0].cellIndex); + const data = survivor?.metadata?.data as any; + assert.strictEqual( + data?.subdivisionNames?.v8, + "Tail EN", + "Target local names on the removed milestone must move onto the surviving milestone" + ); + }); + + test("promote keeps the target milestone label when the translator named the seam locally", async () => { + function buildOneMilestoneWithBreakAtV6() { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: "Luke 1", + metadata: { + type: CodexCellTypes.MILESTONE, + id: "m1", + data: { + subdivisions: [{ startCellId: "v6" }], + }, + }, + }, + ]; + for (let i = 1; i <= 10; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + return cells; + } + + const sourceCells = buildOneMilestoneWithBreakAtV6(); + const targetCells = buildOneMilestoneWithBreakAtV6(); + targetCells[0].metadata.data = { + ...targetCells[0].metadata.data, + subdivisionNames: { v6: "Translator section" }, + }; + + const sourceDoc = await createDocumentWithCells(sourceCells); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument(targetCells); + stubProviderForStructuralMirror(provider, targetDoc, targetUri); + + await invokeHandler("promoteSubdivisionToMilestone", { + document: sourceDoc, + content: { milestoneIndex: 0, subdivisionKey: "v6" }, + }); + + const targetAfter = targetDoc.buildMilestoneIndex(50); + assert.strictEqual(targetAfter.milestones.length, 2, "Target should have split mirroring source"); + + const newMs = targetDoc.getCellByIndex(targetAfter.milestones[1].cellIndex); + assert.strictEqual( + newMs?.value, + "Translator section", + "Target-only name at the promoted seam should win for the new milestone label" + ); + }); + + test("mirror is skipped when the target's milestone cell ID has diverged", async () => { + // Pair a source whose milestone IDs are m1/m2 against a target + // whose milestone IDs differ. The mirror must NOT touch the + // target's milestone structure. + const sourceDoc = await createDocumentWithCells(buildTwoMilestoneCells()); + stampSourceUri(sourceDoc); + const targetCells = buildTwoMilestoneCells(); + targetCells[6].metadata.id = "different-id"; // the second milestone cell + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument( + targetCells + ); + stubProviderForStructuralMirror(provider, targetDoc, targetUri); + + await invokeHandler("removeMilestone", { + document: sourceDoc, + content: { milestoneIndex: 1 }, + }); + + const targetAfter = targetDoc.buildMilestoneIndex(50); + assert.strictEqual( + targetAfter.milestones.length, + 2, + "Diverged target should be left intact" + ); + }); + }); +}); diff --git a/src/utils/milestoneCellUtils.ts b/src/utils/milestoneCellUtils.ts new file mode 100644 index 000000000..1c511f519 --- /dev/null +++ b/src/utils/milestoneCellUtils.ts @@ -0,0 +1,193 @@ +import { randomUUID } from "crypto"; +import { CodexCellTypes, EditType } from "../../types/enums"; +import { EditMapUtils } from "./editMapUtils"; +import bibleData from "../../webviews/codex-webviews/src/assets/bible-books-lookup.json"; + +/** + * Extracts the chapter number from a structured cellId of the form + * "BOOK CHAPTER:VERSE" (e.g. `"GEN 1:1"` → `"1"`). Returns null when the + * pattern does not match — including for UUID-shaped cell IDs. + */ +export function extractChapterFromCellId(cellId: string): string | null { + if (!cellId) return null; + // Pattern: :(:|end) + // Captures the first digit run as the chapter; tolerates a trailing + // verse-suffix (verse-range disambiguation). + const match = cellId.match(/\s+(\d+):(\d+)(?::|$)/); + if (match) { + return match[1]; + } + return null; +} + +/** + * Resolves a chapter label for a milestone cell using a cascade of metadata + * locations, falling back to the milestone ordinal as the last resort. The + * priority is intentional: explicit Biblica/USFM chapter metadata wins over + * legacy `data.chapter`, which wins over a parsed `cellId`. + */ +export function extractChapterFromCell(cell: any, milestoneOrdinal: number): string { + if (cell?.metadata?.chapterNumber !== undefined && cell.metadata.chapterNumber !== null) { + return String(cell.metadata.chapterNumber); + } + if (cell?.metadata?.chapter !== undefined && cell.metadata.chapter !== null) { + return String(cell.metadata.chapter); + } + if (cell?.metadata?.data?.chapter !== undefined && cell.metadata.data.chapter !== null) { + return String(cell.metadata.data.chapter); + } + const cellId = cell?.metadata?.id || cell?.id; + if (cellId) { + const chapterFromId = extractChapterFromCellId(cellId); + if (chapterFromId) { + return chapterFromId; + } + } + return milestoneOrdinal.toString(); +} + +/** + * Pulls a book abbreviation out of `globalReferences`, then `cellMarkers`, + * then a parsed `cellId`. Returns null when no abbreviation can be found — + * e.g. when the cell is identified by a UUID rather than a scripture-shaped + * id. + */ +export function extractBookNameFromCell(cell: any): string | null { + const globalRefs = cell?.data?.globalReferences || cell?.metadata?.data?.globalReferences; + if (globalRefs && Array.isArray(globalRefs) && globalRefs.length > 0) { + const firstRef = globalRefs[0]; + const bookMatch = firstRef.match(/^([^\s]+)/); + if (bookMatch) { + return bookMatch[1]; + } + } + if (cell?.cellMarkers?.[0]) { + const firstMarker = cell.cellMarkers[0].split(":")[0]; + if (firstMarker) { + const parts = firstMarker.split(" "); + return parts[0]; + } + } + const cellId = cell?.metadata?.id || cell?.id; + if (cellId) { + const bookMatch = cellId.match(/^([^\s]+)/); + if (bookMatch) { + return bookMatch[1]; + } + } + return null; +} + +/** + * Translates a USFM/Biblica book abbreviation to its localized display name + * (e.g. `"GEN"` → `"Genesis"`). Falls through to the abbreviation when no + * mapping is found so the caller never gets back an empty string. + */ +export function getLocalizedBookName(bookAbbr: string): string { + if (!bookAbbr) return bookAbbr; + const bookInfo = (bibleData as any[]).find((book) => book.abbr === bookAbbr); + return bookInfo?.name || bookAbbr; +} + +/** + * Computes the human-readable label for a milestone cell. Prefers + * `"BookName ChapterNumber"` (e.g. `"Isaiah 1"`); falls back to the bare + * chapter number for non-Bible content. + */ +export function buildMilestoneLabelFromCell(cell: any, milestoneOrdinal: number): string { + const chapterNumber = extractChapterFromCell(cell, milestoneOrdinal); + const bookAbbr = extractBookNameFromCell(cell); + const bookName = bookAbbr ? getLocalizedBookName(bookAbbr) : null; + return bookName ? `${bookName} ${chapterNumber}` : chapterNumber; +} + +export interface MilestoneCellPayloadOptions { + /** + * Cell to derive book/chapter context from. The payload's display value + * comes from this cell's metadata (chapter number, book abbreviation). + */ + referenceCell: any; + /** + * 1-indexed milestone ordinal. Used as the final fallback when no + * structured chapter metadata is available. + */ + milestoneOrdinal: number; + /** + * Author name recorded against the initial edit entry. Callers pass + * the current user; `migrationUtils` resolves this through `getAuthApi`, + * the in-editor handler resolves it through `document.refreshAuthor`. + */ + author: string; + /** Stable UUID for the milestone cell. Generated on demand if omitted. */ + uuid?: string; + /** + * Optional override for the milestone label. When omitted we derive it + * from `referenceCell` via `buildMilestoneLabelFromCell`. Callers that + * already have a custom label (e.g. promoting a named subdivision) can + * pass it directly. + */ + valueOverride?: string; + /** + * Optional `metadata.data` blob to attach to the milestone cell at + * creation time. Used by structural edits to persist subdivisions and + * subdivision-name overrides on a brand-new milestone in one shot, + * skipping a follow-up `updateCellData` call. + */ + initialData?: Record; +} + +/** + * Builds the on-disk shape of a milestone notebook cell. Centralising this + * keeps importers, migrations, and in-editor structural edits aligned on + * label format, edit-history shape, and the surrounding kind/languageId + * envelope. + * + * The returned object matches `CustomNotebookCellData` shape for milestone + * cells: `kind: 2` (NotebookCellKind.Code), `languageId: "html"`, an + * INITIAL_IMPORT-style edit entry timestamped slightly in the past so it + * sorts before any subsequent USER_EDIT. + */ +export function buildMilestoneCellPayload(opts: MilestoneCellPayloadOptions): any { + const { + referenceCell, + milestoneOrdinal, + author, + uuid, + valueOverride, + initialData, + } = opts; + const cellUuid = uuid || randomUUID(); + const milestoneValue = + valueOverride && valueOverride.length > 0 + ? valueOverride + : buildMilestoneLabelFromCell(referenceCell, milestoneOrdinal); + const currentTimestamp = Date.now(); + // Initial-import edit anchors the milestone label in the merge log so the + // value survives 3-way merges even when the cell has not yet been touched + // by a USER_EDIT. Stamping the timestamp slightly in the past keeps it + // ordered before any subsequent user edit recorded in the same tick. + const initialEdit = { + editMap: EditMapUtils.value(), + value: milestoneValue, + timestamp: currentTimestamp - 1000, + type: EditType.INITIAL_IMPORT, + author, + validatedBy: [], + }; + + const metadata: any = { + id: cellUuid, + type: CodexCellTypes.MILESTONE, + edits: [initialEdit], + }; + if (initialData && Object.keys(initialData).length > 0) { + metadata.data = { ...initialData }; + } + + return { + kind: 2, // vscode.NotebookCellKind.Code + languageId: "html", + value: milestoneValue, + metadata, + }; +} diff --git a/types/index.d.ts b/types/index.d.ts index 23be1b421..fb69c1899 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -637,6 +637,114 @@ export type EditorPostMessages = newValue: string; }; } + | { + command: "updateMilestoneSubdivisions"; + content: { + milestoneIndex: number; + /** + * Full new list of break anchors for this milestone. Each `startCellId` + * must refer to a current root content cell inside the milestone. + * Pass an empty array to clear all custom subdivisions (falls back to + * arithmetic pagination). + */ + subdivisions: MilestoneSubdivisionPlacement[]; + }; + } + | { + command: "updateMilestoneSubdivisionName"; + content: { + milestoneIndex: number; + /** Stable key identifying the subdivision (typically its `startCellId`). */ + subdivisionKey: string; + /** New display name. Pass an empty string to clear the override. */ + newName: string; + }; + } + | { + /** + * Add a new subdivision break anchored at the Nth root content cell of the + * given milestone (1-based, so `cellNumber=11` means "split starting at the + * 11th content cell of this milestone"). The provider resolves the number + * to a `startCellId`, merges it into the existing placement list, and + * mirrors the updated list to the paired target document. + * + * Writes are rejected from non-source documents at the provider layer; + * callers should also hide the UI on target. + */ + command: "addMilestoneSubdivisionAnchor"; + content: { + milestoneIndex: number; + /** 1-based position of the split point within the milestone's root cells. */ + cellNumber: number; + }; + } + | { + /** + * Insert a new milestone cell at the Nth root content cell of an + * existing milestone, splitting the existing milestone in two. The + * provider resolves the number to a `startCellId`, redistributes the + * existing milestone's subdivisions across the split, and mirrors the + * structural change (insertion + redistributed subdivisions) onto the + * paired target document by UUID. Source-only — the gating setting + * `codex-editor-extension.enableMilestonePlacementEditing` must also + * be on for the UI to render the trigger. + */ + command: "addMilestoneAtCell"; + content: { + milestoneIndex: number; + /** 1-based position within the milestone's root cells (must be >= 2). */ + cellNumber: number; + }; + } + | { + /** + * Soft-delete a milestone cell. The previous (surviving) milestone's + * range expands to absorb the removed milestone's content cells; any + * subdivision breaks inside the removed milestone are lifted onto the + * surviving milestone (their cell-ID anchors stay valid). The + * boundary itself is NOT preserved as a subdivision break — use + * `demoteMilestoneToSubdivision` for that. + * + * The first milestone cannot be removed (would leave the document + * with a virtual milestone). Source-only. + */ + command: "removeMilestone"; + content: { + /** 0-based milestone index in the current source document. */ + milestoneIndex: number; + }; + } + | { + /** + * Convert an existing custom subdivision break into a full milestone. + * The original milestone splits at the subdivision's anchor cell and + * the named subdivision becomes the new milestone's label. Equivalent + * to `addMilestoneAtCell` against an already-promoted anchor, except + * the source's existing placement at the anchor is removed (since + * it's now a milestone, not a subdivision break). + * + * Source-only. The subdivision must currently exist as a `custom` + * placement on the milestone's stored `subdivisions` list. + */ + command: "promoteSubdivisionToMilestone"; + content: { + milestoneIndex: number; + /** Stable subdivision key — the `startCellId` of the placement. */ + subdivisionKey: string; + }; + } + | { + /** + * Convert a milestone into a subdivision break of the previous + * milestone. Like `removeMilestone`, but the boundary is preserved + * as a custom subdivision (carrying the demoted milestone's label + * as its name). Source-only; cannot demote the first milestone. + */ + command: "demoteMilestoneToSubdivision"; + content: { + milestoneIndex: number; + }; + } | { command: "refreshWebviewAfterMilestoneEdits"; content?: Record; @@ -745,8 +853,68 @@ type CodexData = Timestamps & { originalText?: string; globalReferences?: string[]; // Array of cell IDs in original format (e.g., "GEN 1:1") used for header generation milestoneIndex?: number | null; // 0-based milestone index for O(1) lookup (null if no milestone) + /** + * Optional user-defined subdivisions for a milestone cell. Only meaningful when the + * cell has `type === CodexCellTypes.MILESTONE`. The first subdivision is always the + * milestone itself (starts at root-content-cell index 0), so typically only explicit + * subsequent break anchors are persisted. Source documents are authoritative for + * placements. See `resolveSubdivisions`. + */ + subdivisions?: MilestoneSubdivisionPlacement[]; + /** + * Document-local name overrides for a milestone's subdivisions. Keyed by + * `startCellId` (or "__start__" for the implicit first subdivision). Stored + * on either source or target milestone cells; the document that owns the + * map is the one applying the override. Always wins over + * `subdivisionNamesFromSource`. + */ + subdivisionNames?: { [subdivisionKey: string]: string; }; + /** + * Mirrored copy of the source document's `subdivisionNames` map. Only + * populated on TARGET milestone cells. Used as the fallback display name + * when the target's own `subdivisionNames` doesn't have an entry — lets a + * translator see source-side labels by default while still being free to + * rename their own copy. + */ + subdivisionNamesFromSource?: { [subdivisionKey: string]: string; }; }; +/** + * A user-defined subdivision anchor within a milestone. `startCellId` is the stable + * id of the first root content cell of the subdivision. The implicit first + * subdivision (covering the start of the milestone) does not need an entry here; + * placements describe the breaks AFTER the start. + */ +export interface MilestoneSubdivisionPlacement { + /** Stable anchor — cell ID of the first root content cell of this subdivision. */ + startCellId: string; + /** Optional name (source-authoritative when stored on a source milestone). */ + name?: string; +} + +/** + * Resolved subdivision, ready for rendering/pagination. Produced by + * `resolveSubdivisions` at the provider layer. Root-index boundaries refer to + * the ordered list of root content cells (i.e. non-milestone, non-paratext, + * non-deleted cells without `parentId`) within a milestone. + */ +export interface SubdivisionInfo { + /** 0-based index of this subdivision within its milestone. */ + index: number; + /** Inclusive start in root-content-cell space. */ + startRootIndex: number; + /** Exclusive end in root-content-cell space. */ + endRootIndex: number; + /** Stable key for name overrides and for rendering stable React keys. */ + key: string; + /** Root content cell ID at `startRootIndex`, when a cell exists. */ + startCellId?: string; + /** Display name (source-stored name or target override). Callers format fallbacks. */ + name?: string; + /** "custom" = user-defined; "auto" = arithmetic chunk or auto-tail subdivision. */ + source: "auto" | "custom"; +} + type BaseCustomCellMetaData = { id: string; type: CodexCellTypes; @@ -941,6 +1109,15 @@ export interface MilestoneInfo { value: string; /** Number of content cells in this milestone section (excluding milestone cell itself) */ cellCount: number; + /** + * Resolved subdivisions for this milestone, in order. When present, overrides the + * arithmetic `cellsPerPage` pagination. Computed by + * `codexDocument.buildMilestoneIndex` based on the milestone cell's stored + * `metadata.data.subdivisions` (plus `metadata.data.subdivisionNames` on target + * documents). If no custom subdivisions exist, this is an array of one or more + * auto-subdivisions matching the arithmetic chunking. + */ + subdivisions?: SubdivisionInfo[]; } /** @@ -1914,6 +2091,31 @@ type EditorReceiveMessages = validationCountAudio?: number; isAuthenticated?: boolean; userAccessLevel?: number; + /** + * When true, milestone subdivisions always display their numeric cell + * range even when a user-assigned name exists. Mirrors the workspace + * setting `codex-editor-extension.useSubdivisionNumberLabels`. + */ + useSubdivisionNumberLabels?: boolean; + /** + * When true, the milestone-placement editing controls render in the + * MilestoneAccordion's settings mode (add/remove/promote/demote). + * Mirrors `codex-editor-extension.enableMilestonePlacementEditing`. + * Off by default — the feature is gated behind a setting because it + * restructures the document, not just relabels regions. + */ + enableMilestonePlacementEditing?: boolean; + /** + * When true, this payload is a server-initiated push (e.g. after a + * structural milestone edit on the source or a mirror from another + * webview) and must be applied even when the webview's tracked + * position no longer matches `currentMilestoneIndex` / + * `currentSubsectionIndex`. The webview should also realign its refs + * to the message's position. Without this flag the webview's stale + * guard would reject the message and leave the accordion frozen on + * the pre-edit structure. + */ + force?: boolean; } | { type: "providerSendsCellPage"; @@ -2125,6 +2327,13 @@ type EditorReceiveMessages = /** Optional position from provider; webview uses this when present to avoid reverting during navigation. */ milestoneIndex?: number; subsectionIndex?: number; + /** + * When true, this refresh follows a server-initiated structural + * change that shifted the cursor. The webview must request cells at + * the message's `milestoneIndex` / `subsectionIndex` instead of its + * own (now-stale) refs. + */ + force?: boolean; } | { type: "asrConfig"; content: { endpoint: string; authToken?: string; }; } | { type: "startBatchTranscription"; content: { count: number; }; } @@ -2213,6 +2422,24 @@ type EditorReceiveMessages = type: "updateCellsPerPage"; cellsPerPage: number; } + | { + type: "updateSubdivisionLabelPreference"; + /** + * When true, milestone subdivisions always display their numeric cell + * range instead of the user-assigned name. Mirrors + * `codex-editor-extension.useSubdivisionNumberLabels`. + */ + useSubdivisionNumberLabels: boolean; + } + | { + type: "updateMilestonePlacementEditingPreference"; + /** + * When true, the MilestoneAccordion's settings mode reveals + * milestone-placement editing controls (add/remove/promote/demote). + * Mirrors `codex-editor-extension.enableMilestonePlacementEditing`. + */ + enableMilestonePlacementEditing: boolean; + } | { type: "editorPosition"; position: "leftmost" | "rightmost" | "center" | "single" | "unknown"; diff --git a/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx b/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx index 963a49676..b779808e1 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx @@ -90,6 +90,17 @@ interface ChapterNavigationHeaderProps { subsectionProgress?: Record; allSubsectionProgress?: Record>; requestSubsectionProgress?: (milestoneIdx: number) => void; + /** + * When true, milestone subdivisions display their numeric cell range even + * when a user-assigned name is available. Defaults to false. + */ + useSubdivisionNumberLabels?: boolean; + /** + * When true, the milestone-placement editing controls render in the + * MilestoneAccordion's settings mode (add/remove/promote/demote). + * Defaults to false; gated behind `codex-editor-extension.enableMilestonePlacementEditing`. + */ + enableMilestonePlacementEditing?: boolean; } export function ChapterNavigationHeader({ @@ -149,12 +160,20 @@ export function ChapterNavigationHeader({ subsectionProgress, allSubsectionProgress, requestSubsectionProgress, + useSubdivisionNumberLabels = false, + enableMilestonePlacementEditing = false, }: // Removed onToggleCorrectionEditor since it will be a VS Code command now ChapterNavigationHeaderProps) { const [showConfirm, setShowConfirm] = useState(false); const [isMetadataModalOpen, setIsMetadataModalOpen] = useState(false); const [autoDownloadAudioOnOpen, setAutoDownloadAudioOnOpenState] = useState(false); const [showMilestoneAccordion, setShowMilestoneAccordion] = useState(false); + // Stable callback so MilestoneAccordion's effect deps don't churn on every + // render of this header — keeps inline-rename focus from being stolen when + // we re-attach ESC / click-outside listeners. + const closeMilestoneAccordion = useCallback(() => { + setShowMilestoneAccordion(false); + }, []); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const chapterTitleRef = useRef(null); const headerContainerRef = useRef(null); @@ -1065,7 +1084,7 @@ ChapterNavigationHeaderProps) { setShowMilestoneAccordion(false)} + onClose={closeMilestoneAccordion} milestoneIndex={milestoneIndex} currentMilestoneIndex={currentMilestoneIndex} currentSubsectionIndex={currentSubsectionIndex} @@ -1078,6 +1097,8 @@ ChapterNavigationHeaderProps) { calculateSubsectionProgress={calculateSubsectionProgress} requestSubsectionProgress={requestSubsectionProgress} vscode={vscode} + useSubdivisionNumberLabels={useSubdivisionNumberLabels} + enableMilestonePlacementEditing={enableMilestonePlacementEditing} /> ); diff --git a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx index 6ce756f31..3dfa2a468 100755 --- a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx @@ -34,6 +34,7 @@ import { import "./TranslationAnimations.css"; import { getVSCodeAPI } from "../shared/vscodeApi"; import { Subsection, ProgressPercentages } from "../lib/types"; +import { buildSubsectionsForMilestone } from "./utils/subdivisionUtils"; import { ABTestVariantSelector } from "./components/ABTestVariantSelector"; import { useMessageHandler } from "./hooks/useCentralizedMessageDispatcher"; import { createCacheHelpers, createProgressCacheHelpers } from "./utils"; @@ -253,6 +254,18 @@ const CodexCellEditor: React.FC = () => { (window as any)?.initialData?.validationCountAudio ?? null ); + // Workspace preference: force numeric labels on subdivisions. Initialized + // from the provider's first content payload and kept in sync via + // `updateSubdivisionLabelPreference` messages; default false when absent. + const [useSubdivisionNumberLabels, setUseSubdivisionNumberLabels] = useState(false); + + // Workspace opt-in for milestone-placement editing controls + // (add/remove/promote/demote). Initialized from the provider's first + // content payload and kept in sync via + // `updateMilestonePlacementEditingPreference` messages; default false. + const [enableMilestonePlacementEditing, setEnableMilestonePlacementEditing] = + useState(false); + // Track cells currently transcribing audio (to show the same loading effect as translations) const [transcribingCells, setTranscribingCells] = useState>(new Set()); @@ -950,14 +963,31 @@ const CodexCellEditor: React.FC = () => { milestoneCellsCacheRef.current.clear(); progressCacheRef.current.clear(); - // Prefer: 1) in-flight navigation (latestRequestRef), 2) refs (webview's current position). - // Always use refs over the provider message so our position wins when the provider sends a stale - // position (e.g. source doc: provider hasn't processed our request yet). Refs are updated when we - // navigate, use cache, or receive handleCellPage/setContentPaginated. - const pending = latestRequestRef.current; + // Position selection cascade: + // 1. `force: true` from server (structural edit shifted the cursor) → use + // the message's position; refs are stale. + // 2. In-flight navigation (`latestRequestRef`) → use that. + // 3. Webview refs (current position). + // Refs win over a non-forced provider message so our position is preserved + // when the provider sends a stale refresh (e.g. user navigated mid-flight). + const forcedPosition = + message.force === true && + typeof message.milestoneIndex === "number" && + typeof message.subsectionIndex === "number" + ? { milestoneIdx: message.milestoneIndex, subsectionIdx: message.subsectionIndex } + : null; + const pending = forcedPosition ?? latestRequestRef.current; const milestoneIdx = pending?.milestoneIdx ?? currentMilestoneIndexRef.current; const subsectionIdx = pending?.subsectionIdx ?? currentSubsectionIndexRef.current; + // Realign refs immediately on a forced refresh so a follow-up + // providerSendsInitialContentPaginated isn't bounced as stale. + if (forcedPosition) { + latestRequestRef.current = null; + currentMilestoneIndexRef.current = forcedPosition.milestoneIdx; + currentSubsectionIndexRef.current = forcedPosition.subsectionIdx; + } + // Request fresh cells for the current page if (requestCellsForMilestoneRef.current) { requestCellsForMilestoneRef.current(milestoneIdx, subsectionIdx); @@ -1683,7 +1713,8 @@ const CodexCellEditor: React.FC = () => { currentMilestoneIdx: number, currentSubsectionIdx: number, isSourceTextValue: boolean, - sourceCellMapValue: { [k: string]: { content: string; versions: string[] } } + sourceCellMapValue: { [k: string]: { content: string; versions: string[] } }, + force?: boolean ) => { // On first load, always accept the initial content regardless of ref values. // The refs start at (0,0) but the provider may send a cached position (e.g. chapter 3 → milestone 2), @@ -1692,9 +1723,12 @@ const CodexCellEditor: React.FC = () => { // Ignore initial content when we're already on a different page (e.g. source: provider sent // providerSendsInitialContentPaginated (0,0) after we navigated to (0,1), which would revert us). - // But never reject the very first content message - that's our initial load. + // But never reject the very first content message - that's our initial load. `force: true` + // marks server-initiated structural updates (e.g. promote/demote shifts the cursor); those + // must apply regardless of refs and realign refs to the message's position. if ( !isFirstContent && + !force && (currentMilestoneIndexRef.current !== currentMilestoneIdx || currentSubsectionIndexRef.current !== currentSubsectionIdx) ) { @@ -1710,6 +1744,12 @@ const CodexCellEditor: React.FC = () => { ); return; } + if (force) { + // A structural edit on the server side just shifted the cursor; + // drop any in-flight navigation request so it doesn't reapply + // the pre-edit position over the new state. + latestRequestRef.current = null; + } // Mark that we've received initial content so subsequent messages go through the stale guard hasReceivedInitialContentRef.current = true; @@ -1933,38 +1973,8 @@ const CodexCellEditor: React.FC = () => { } const milestone = milestoneIndex.milestones[milestoneIdx]; - const { cellCount, value } = milestone; const effectiveCellsPerPage = milestoneIndex.cellsPerPage || cellsPerPage; - - // When milestone has 0 cells, return a single empty subsection (avoid invalid "1-0" label) - if (cellCount === 0) { - return [ - { - id: `milestone-${milestoneIdx}-page-0`, - label: "0", - startIndex: 0, - endIndex: 0, - }, - ]; - } - - // Calculate number of pages based on content cells - const totalPages = Math.ceil(cellCount / effectiveCellsPerPage) || 1; - const subsections: Subsection[] = []; - - for (let i = 0; i < totalPages; i++) { - const startCellNumber = i * effectiveCellsPerPage + 1; - const endCellNumber = Math.min((i + 1) * effectiveCellsPerPage, cellCount); - - subsections.push({ - id: `milestone-${milestoneIdx}-page-${i}`, - label: `${startCellNumber}-${endCellNumber}`, - startIndex: i * effectiveCellsPerPage, - endIndex: endCellNumber, - }); - } - - return subsections; + return buildSubsectionsForMilestone(milestoneIdx, milestone, effectiveCellsPerPage); }, [milestoneIndex, cellsPerPage] ); @@ -2448,6 +2458,28 @@ const CodexCellEditor: React.FC = () => { if (event.data.userAccessLevel !== undefined) { setUserAccessLevel(event.data.userAccessLevel); } + if (event.data.useSubdivisionNumberLabels !== undefined) { + setUseSubdivisionNumberLabels( + Boolean(event.data.useSubdivisionNumberLabels) + ); + } + if (event.data.enableMilestonePlacementEditing !== undefined) { + setEnableMilestonePlacementEditing( + Boolean(event.data.enableMilestonePlacementEditing) + ); + } + } + + if (event.data.type === "updateSubdivisionLabelPreference") { + setUseSubdivisionNumberLabels( + Boolean(event.data.useSubdivisionNumberLabels) + ); + } + + if (event.data.type === "updateMilestonePlacementEditingPreference") { + setEnableMilestonePlacementEditing( + Boolean(event.data.enableMilestonePlacementEditing) + ); } }, [] @@ -3346,6 +3378,8 @@ const CodexCellEditor: React.FC = () => { subsectionProgress={subsectionProgress[currentMilestoneIndex]} allSubsectionProgress={subsectionProgress} requestSubsectionProgress={requestSubsectionProgressForMilestone} + useSubdivisionNumberLabels={useSubdivisionNumberLabels} + enableMilestonePlacementEditing={enableMilestonePlacementEditing} /> diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/initialContentStaleGuard.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/__tests___/initialContentStaleGuard.test.tsx index dcb8be6c9..9778b7630 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/initialContentStaleGuard.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/initialContentStaleGuard.test.tsx @@ -65,13 +65,15 @@ function StaleGuardHarness(props: { currentMilestoneIdx: number, currentSubsectionIdx: number, _isSourceTextValue: boolean, - _sourceCellMapValue: { [k: string]: { content: string; versions: string[] } } + _sourceCellMapValue: { [k: string]: { content: string; versions: string[] } }, + force?: boolean ) => { // ---- Exact same guard logic as CodexCellEditor.tsx ---- const isFirstContent = !hasReceivedInitialContentRef.current; if ( !isFirstContent && + !force && (currentMilestoneIndexRef.current !== currentMilestoneIdx || currentSubsectionIndexRef.current !== currentSubsectionIdx) ) { @@ -111,13 +113,15 @@ const dispatchInitialContent = ( cells: QuillCellContent[], currentMilestoneIndex: number, currentSubsectionIndex: number, - rev?: number + rev?: number, + force?: boolean ) => { window.dispatchEvent( new MessageEvent("message", { data: { type: "providerSendsInitialContentPaginated", ...(rev !== undefined ? { rev } : {}), + ...(force !== undefined ? { force } : {}), milestoneIndex, cells, currentMilestoneIndex, @@ -259,4 +263,73 @@ describe("setContentPaginated stale-content guard (initial load)", () => { expect(onAccepted).toHaveBeenCalledTimes(2); expect(onRejected).not.toHaveBeenCalled(); }); + + it("force=true bypasses the stale guard so server-initiated structural updates always apply", () => { + // Scenario: user has scrolled to milestone 2 (refs are (2, 0)). The user + // clicks demote on milestone 2 on the source side; the provider merges + // milestone 2 into milestone 1 and shifts the cached cursor to (1, 0). + // Without `force` the webview would compare refs (2, 0) !== (1, 0) and + // reject the refresh, leaving the accordion frozen on the pre-demote + // structure. With `force` the message is applied unconditionally and + // refs realign to (1, 0). + const onAccepted = vi.fn(); + const onRejected = vi.fn(); + + render(); + + const beforeDemote = mkMilestoneIndex([ + { value: "Mark 1", cellIndex: 0 }, + { value: "Mark 2", cellIndex: 50 }, + { value: "Mark 3", cellIndex: 100 }, + ]); + + act(() => { + dispatchInitialContent( + beforeDemote, + [mkCell("MRK 3:1", "chapter 3")], + 2, + 0, + 1 + ); + }); + expect(onAccepted).toHaveBeenCalledTimes(1); + + // Provider issues a forced refresh after demote: shifted to milestone 1. + const afterDemote = mkMilestoneIndex([ + { value: "Mark 1", cellIndex: 0 }, + { value: "Mark 3", cellIndex: 100 }, + ]); + act(() => { + dispatchInitialContent( + afterDemote, + [mkCell("MRK 2:1", "merged")], + 1, + 0, + 2, + true + ); + }); + + expect(onAccepted).toHaveBeenCalledTimes(2); + expect(onAccepted).toHaveBeenLastCalledWith(1, 0, [ + expect.objectContaining({ cellMarkers: ["MRK 2:1"] }), + ]); + expect(onRejected).not.toHaveBeenCalled(); + + // After the forced refresh, refs are now (1, 0). A subsequent stale + // (non-forced) message for milestone 0 must still be rejected — the + // force flag is per-message, not a permanent override. + act(() => { + dispatchInitialContent( + afterDemote, + [mkCell("MRK 1:1", "ch1")], + 0, + 0, + 3 + ); + }); + expect(onAccepted).toHaveBeenCalledTimes(2); + expect(onRejected).toHaveBeenCalledTimes(1); + expect(onRejected).toHaveBeenCalledWith(0, 0); + }); }); diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx index 23eb68b11..474302a3e 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen, fireEvent, act } from "@testing-library/react"; +import { render, screen, fireEvent, act, within } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import "@testing-library/jest-dom/vitest"; import { MilestoneAccordion } from "./MilestoneAccordion"; @@ -15,6 +15,7 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ appearance, title, "aria-label": ariaLabel, + "aria-pressed": ariaPressed, }: any) => ( @@ -57,12 +59,14 @@ vi.mock("./ProgressDots", () => ({ ProgressDots: () =>
ProgressDots
, })); -// Mock icons -vi.mock("lucide-react", () => ({ - Languages: () =>
Languages
, - Check: () =>
Check
, - RotateCcw: () =>
RotateCcw
, -})); +// Mock icons. Use importOriginal so any new icon imported by the component +// (e.g. Trash2) is automatically available in tests without having to be +// re-listed here. The previous explicit-list approach silently broke tests +// every time a new icon was introduced. +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); vi.mock("../../components/ui/icons/MicrophoneIcon", () => ({ default: () =>
Microphone
, @@ -152,6 +156,28 @@ describe("MilestoneAccordion - Milestone Editing", () => { vi.restoreAllMocks(); }); + /** + * Scope-helpers for per-milestone queries. + * + * The accordion renders one row per milestone, each wrapped in + * `data-testid="accordion-item-${idx}"` (see the AccordionItem mock above). + * The "Rename Milestone" pencil now lives on every row, so an unscoped + * `getByLabelText("Rename Milestone")` would match N elements and throw. + * Tests almost always exercise the *current* milestone (idx 0 by default), + * so we expose a small helper that scopes the lookup to that row. + * + * Use `getRenameMilestoneButton(idx)` for "must exist" assertions and + * `queryRenameMilestoneButton(idx)` for "must not exist" assertions on a + * specific row. For "no rename pencils anywhere" (e.g. settings mode off) + * use `screen.queryAllByLabelText("Rename Milestone")`. + */ + const getMilestoneRow = (milestoneIdx: number = 0): HTMLElement => + screen.getByTestId(`accordion-item-${milestoneIdx}`); + const getRenameMilestoneButton = (milestoneIdx: number = 0): HTMLElement => + within(getMilestoneRow(milestoneIdx)).getByLabelText("Rename Milestone"); + const queryRenameMilestoneButton = (milestoneIdx: number = 0): HTMLElement | null => + within(getMilestoneRow(milestoneIdx)).queryByLabelText("Rename Milestone"); + function renderMilestoneAccordion( props: Partial> = {} ) { @@ -174,41 +200,52 @@ describe("MilestoneAccordion - Milestone Editing", () => { calculateSubsectionProgress: mockCalculateSubsectionProgress, requestSubsectionProgress: mockRequestSubsectionProgress, vscode: mockVscode, + // Most rename-flow tests assert directly against the milestone + // pencil, per-milestone-subdivision pencils, and add-break + // controls. Those affordances are now gated behind the + // gear/settings toggle, so we open the accordion already in + // settings mode by default and let the dedicated gear-toggle + // tests override this with `false`. + initialSettingsMode: true, }; return render(); } - describe("Edit Mode - Starting Edit", () => { - it("should enter edit mode when edit button is clicked", async () => { + describe("Milestone Rename - Starting", () => { + it("swaps the milestone row's pencil for a save/cancel cluster + inline input", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); - expect(editButton).toBeInTheDocument(); + const renameButton = getRenameMilestoneButton(); + expect(renameButton).toBeInTheDocument(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); - // Should show input field + // The row now hosts the rename input, prefilled with the + // milestone's current display value (no longer in the dropdown + // header — the header keeps showing the static

+ gear). const input = screen.getByDisplayValue("Chapter 1"); expect(input).toBeInTheDocument(); expect(input.tagName).toBe("INPUT"); - // Should show save and revert buttons - expect(screen.getByLabelText("Save Milestone")).toBeInTheDocument(); - expect(screen.getByLabelText("Revert Changes")).toBeInTheDocument(); - - // Edit button should not be visible - expect(screen.queryByLabelText("Edit Milestone")).not.toBeInTheDocument(); + // Save + Cancel replace the row's pencil/destructive cluster. + expect(screen.getByLabelText("Save Milestone Rename")).toBeInTheDocument(); + expect(screen.getByLabelText("Cancel Milestone Rename")).toBeInTheDocument(); + // Gear stays in the header during rename — inline editing is + // anchored to the row, not a header swap. + expect( + screen.queryByLabelText("Toggle Milestone Settings") + ).toBeInTheDocument(); }); it("should initialize input with current milestone value", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -218,9 +255,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should show input field when entering edit mode", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); // Input should be visible and editable @@ -230,14 +267,14 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Edit Mode - Saving Changes", () => { + describe("Milestone Rename - Saving Changes", () => { it("should save milestone when save button is clicked with valid value", async () => { renderMilestoneAccordion(); // Enter edit mode - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); // Change the value @@ -247,7 +284,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); // Click save - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -271,9 +308,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should trim whitespace when saving", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -281,7 +318,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: " Trimmed Chapter 1 " } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -298,9 +335,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should not save if value is empty after trimming", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -308,7 +345,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: " " } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); expect(saveButton).toBeDisabled(); await act(async () => { @@ -322,12 +359,12 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should not save if value hasn't changed", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); expect(saveButton).toBeDisabled(); await act(async () => { @@ -341,9 +378,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should update local cache immediately after saving", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -351,7 +388,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Cached Chapter 1" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -365,9 +402,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should handle save when milestone index is valid", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -375,7 +412,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Valid Save" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -391,13 +428,13 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Edit Mode - Reverting Changes", () => { + describe("Milestone Rename - Reverting Changes", () => { it("should revert to original value when revert button is clicked", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -405,7 +442,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Changed Value" } }); }); - const revertButton = screen.getByLabelText("Revert Changes"); + const revertButton = screen.getByLabelText("Cancel Milestone Rename"); await act(async () => { fireEvent.click(revertButton); }); @@ -420,9 +457,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should not send postMessage when reverting", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -430,7 +467,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Changed Value" } }); }); - const revertButton = screen.getByLabelText("Revert Changes"); + const revertButton = screen.getByLabelText("Cancel Milestone Rename"); await act(async () => { fireEvent.click(revertButton); }); @@ -440,13 +477,13 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Edit Mode - Keyboard Shortcuts", () => { + describe("Milestone Rename - Keyboard Shortcuts", () => { it("should save when Enter key is pressed", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -470,9 +507,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should revert when Escape key is pressed", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -497,9 +534,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should prevent default behavior for Enter key", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -528,9 +565,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should prevent default behavior for Escape key", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -554,14 +591,14 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Edit Mode - Local Cache", () => { + describe("Milestone Rename - Local Cache", () => { it("should use cached value when displaying previously edited milestone", async () => { renderMilestoneAccordion(); // Edit and save milestone 0 - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -569,7 +606,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Saved Chapter 1" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -586,9 +623,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { renderMilestoneAccordion(); // Edit and save milestone 0 - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -596,7 +633,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Cached Chapter 1" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -611,13 +648,13 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Edit Mode - Button States", () => { + describe("Milestone Rename - Button States", () => { it("should disable save button when value is empty", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -625,16 +662,16 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); expect(saveButton).toBeDisabled(); }); it("should disable save button when value is only whitespace", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -642,28 +679,28 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: " " } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); expect(saveButton).toBeDisabled(); }); it("should disable save button when value hasn't changed", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); expect(saveButton).toBeDisabled(); }); it("should enable save button when value has changed", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -671,42 +708,42 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "New Value" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); expect(saveButton).not.toBeDisabled(); }); it("should always enable revert button", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); - const revertButton = screen.getByLabelText("Revert Changes"); + const revertButton = screen.getByLabelText("Cancel Milestone Rename"); expect(revertButton).not.toBeDisabled(); }); }); - describe("Edit Mode - Source Text Mode", () => { - it("should show edit button when isSourceText is true", () => { + describe("Milestone Rename - Source Text Mode", () => { + it("renders the Rename Milestone pencil on source documents", () => { renderMilestoneAccordion({ isSourceText: true }); - expect(screen.getByLabelText("Edit Milestone")).toBeInTheDocument(); + expect(getRenameMilestoneButton()).toBeInTheDocument(); }); - it("should show edit button when isSourceText is false", () => { + it("renders the Rename Milestone pencil on target documents", () => { renderMilestoneAccordion({ isSourceText: false }); - expect(screen.getByLabelText("Edit Milestone")).toBeInTheDocument(); + expect(getRenameMilestoneButton()).toBeInTheDocument(); }); it("should allow editing milestones in source files", async () => { renderMilestoneAccordion({ isSourceText: true }); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -714,7 +751,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Source Chapter 1" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -732,9 +769,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should allow editing milestones in target files", async () => { renderMilestoneAccordion({ isSourceText: false }); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -742,7 +779,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Target Chapter 1" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -758,13 +795,823 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Edit Mode - Accordion Close (no refresh on close)", () => { + describe("Milestone Subdivision Rename", () => { + const createSubsectionWithKey = ( + id: string, + label: string, + key: string, + name?: string + ): Subsection => ({ + id, + label, + startIndex: 0, + endIndex: 5, + key, + name, + startCellId: key, + source: "custom", + }); + + it("renders rename button only for milestone subdivisions that carry a key", async () => { + mockGetSubsectionsForMilestone = vi.fn((milestoneIdx: number) => [ + createSubsectionWithKey( + `s-${milestoneIdx}-0`, + "1-5", + "__start__", + undefined + ), + createSubsectionWithKey(`s-${milestoneIdx}-1`, "6-10", "v6", "Second Half"), + // Legacy/arithmetic subsection with no key → should not expose rename + { + id: `s-${milestoneIdx}-legacy`, + label: "11-15", + startIndex: 10, + endIndex: 15, + }, + ]); + renderMilestoneAccordion({ + milestoneIndex: createMockMilestoneIndex([{ value: "Luke 1", index: 0 }]), + getSubsectionsForMilestone: mockGetSubsectionsForMilestone, + }); + + const renameButtons = await screen.findAllByLabelText("Rename Milestone Subdivision"); + // Two keyed subsections → two rename affordances; the legacy one is omitted. + expect(renameButtons).toHaveLength(2); + }); + + it("displays the name and keeps the numeric range visible", async () => { + mockGetSubsectionsForMilestone = vi.fn((milestoneIdx: number) => [ + createSubsectionWithKey(`s-${milestoneIdx}-0`, "1-5", "__start__", "Intro"), + ]); + renderMilestoneAccordion({ + milestoneIndex: createMockMilestoneIndex([{ value: "Luke 1", index: 0 }]), + getSubsectionsForMilestone: mockGetSubsectionsForMilestone, + }); + + // Name is primary; label is rendered alongside as a muted suffix. + expect(await screen.findByText("Intro")).toBeInTheDocument(); + expect(screen.getByText("1-5")).toBeInTheDocument(); + }); + + it("posts updateMilestoneSubdivisionName when the milestone subdivision rename is saved", async () => { + mockGetSubsectionsForMilestone = vi.fn((milestoneIdx: number) => [ + createSubsectionWithKey(`s-${milestoneIdx}-0`, "1-5", "v1"), + ]); + renderMilestoneAccordion({ + milestoneIndex: createMockMilestoneIndex([{ value: "Luke 1", index: 0 }]), + getSubsectionsForMilestone: mockGetSubsectionsForMilestone, + }); + + const renameBtn = await screen.findByLabelText("Rename Milestone Subdivision"); + await act(async () => { + fireEvent.click(renameBtn); + }); + + const inputs = await screen.findAllByPlaceholderText("1-5"); + const input = inputs[0] as HTMLInputElement; + await act(async () => { + fireEvent.change(input, { target: { value: "Opening" } }); + }); + + const saveBtn = screen.getByLabelText("Save Milestone Subdivision Rename"); + await act(async () => { + fireEvent.click(saveBtn); + }); + + expect(mockVscode.postMessage).toHaveBeenCalledWith({ + command: "updateMilestoneSubdivisionName", + content: { + milestoneIndex: 0, + subdivisionKey: "v1", + newName: "Opening", + }, + }); + }); + + it("sends an empty string to clear the name override", async () => { + mockGetSubsectionsForMilestone = vi.fn((milestoneIdx: number) => [ + createSubsectionWithKey(`s-${milestoneIdx}-0`, "1-5", "v1", "Opening"), + ]); + renderMilestoneAccordion({ + milestoneIndex: createMockMilestoneIndex([{ value: "Luke 1", index: 0 }]), + getSubsectionsForMilestone: mockGetSubsectionsForMilestone, + }); + + const renameBtn = await screen.findByLabelText("Rename Milestone Subdivision"); + await act(async () => { + fireEvent.click(renameBtn); + }); + + const input = screen.getByDisplayValue("Opening") as HTMLInputElement; + await act(async () => { + fireEvent.change(input, { target: { value: "" } }); + }); + + const saveBtn = screen.getByLabelText("Save Milestone Subdivision Rename"); + await act(async () => { + fireEvent.click(saveBtn); + }); + + expect(mockVscode.postMessage).toHaveBeenCalledWith({ + command: "updateMilestoneSubdivisionName", + content: { + milestoneIndex: 0, + subdivisionKey: "v1", + newName: "", + }, + }); + }); + + it("does not post anything when the name is unchanged", async () => { + mockGetSubsectionsForMilestone = vi.fn((milestoneIdx: number) => [ + createSubsectionWithKey(`s-${milestoneIdx}-0`, "1-5", "v1", "Opening"), + ]); + renderMilestoneAccordion({ + milestoneIndex: createMockMilestoneIndex([{ value: "Luke 1", index: 0 }]), + getSubsectionsForMilestone: mockGetSubsectionsForMilestone, + }); + + const renameBtn = await screen.findByLabelText("Rename Milestone Subdivision"); + await act(async () => { + fireEvent.click(renameBtn); + }); + + const saveBtn = screen.getByLabelText("Save Milestone Subdivision Rename"); + await act(async () => { + fireEvent.click(saveBtn); + }); + + const renameCalls = mockVscode.postMessage.mock.calls.filter( + (call: any[]) => call[0]?.command === "updateMilestoneSubdivisionName" + ); + expect(renameCalls).toHaveLength(0); + }); + + // Regression: the accordion used to call `accordionRef.current.focus()` + // inside the same effect that registered ESC + click-outside listeners, + // with the parent-supplied `onClose` in its deps. Every parent re-render + // produced a new inline `onClose` arrow, which churned the deps and + // re-stole focus from any open inline rename input — making it impossible + // to keep typing in the subdivision rename textbox. This test guards + // against that by changing `onClose` between renders and asserting the + // subdivision rename input retains focus. + it("retains subdivision-rename input focus across parent re-renders that change onClose", async () => { + mockGetSubsectionsForMilestone = vi.fn((milestoneIdx: number) => [ + createSubsectionWithKey(`s-${milestoneIdx}-0`, "1-5", "v1", "Opening"), + ]); + const milestoneIndex = createMockMilestoneIndex([{ value: "Luke 1", index: 0 }]); + const renderArgs = { + milestoneIndex, + getSubsectionsForMilestone: mockGetSubsectionsForMilestone, + } as Partial>; + // Use a stable wrapper so we can rerender with a new `onClose` + // identity (mirroring the inline-arrow pattern parents originally + // used) without unmounting the component under test. + const { rerender } = renderMilestoneAccordion({ + ...renderArgs, + onClose: vi.fn(), + }); + + const renameBtn = await screen.findByLabelText("Rename Milestone Subdivision"); + await act(async () => { + fireEvent.click(renameBtn); + }); + + const input = screen.getByDisplayValue("Opening") as HTMLInputElement; + // Simulate a user click into the input (jsdom auto-focuses on .focus()). + await act(async () => { + input.focus(); + }); + expect(document.activeElement).toBe(input); + + // Force a re-render with a fresh `onClose` reference, mimicking a + // parent re-render that passes a new inline arrow. The focus + // useEffect must NOT yank focus back to the accordion wrapper. + await act(async () => { + rerender( + + ); + }); + + expect(document.activeElement).toBe(input); + }); + + it("cancel button leaves the existing name untouched", async () => { + mockGetSubsectionsForMilestone = vi.fn((milestoneIdx: number) => [ + createSubsectionWithKey(`s-${milestoneIdx}-0`, "1-5", "v1", "Opening"), + ]); + renderMilestoneAccordion({ + milestoneIndex: createMockMilestoneIndex([{ value: "Luke 1", index: 0 }]), + getSubsectionsForMilestone: mockGetSubsectionsForMilestone, + }); + + const renameBtn = await screen.findByLabelText("Rename Milestone Subdivision"); + await act(async () => { + fireEvent.click(renameBtn); + }); + + const input = screen.getByDisplayValue("Opening") as HTMLInputElement; + await act(async () => { + fireEvent.change(input, { target: { value: "Something Else" } }); + }); + + const cancelBtn = screen.getByLabelText("Cancel Milestone Subdivision Rename"); + await act(async () => { + fireEvent.click(cancelBtn); + }); + + const renameCalls = mockVscode.postMessage.mock.calls.filter( + (call: any[]) => call[0]?.command === "updateMilestoneSubdivisionName" + ); + expect(renameCalls).toHaveLength(0); + // Original name still shown (and range-only label preserved) + expect(screen.getByText("Opening")).toBeInTheDocument(); + }); + }); + + describe("Milestone Subdivision Delete and Reset (source only)", () => { + const makeSubsection = ( + id: string, + label: string, + key: string, + source: "auto" | "custom", + startCellId?: string, + name?: string + ): Subsection => ({ + id, + label, + startIndex: 0, + endIndex: 5, + key, + name, + startCellId, + source, + }); + + // Mirror the provider-produced MilestoneIndex so placement derivation + // reads real data (vs. the lightweight createMockMilestoneIndex). + const createIndexWithSubdivisions = (): MilestoneIndex => ({ + milestones: [ + { + index: 0, + cellIndex: 0, + value: "Luke 1", + cellCount: 30, + subdivisions: [ + { + index: 0, + startRootIndex: 0, + endRootIndex: 5, + key: "__start__", + startCellId: "v1", + source: "auto", + }, + { + index: 1, + startRootIndex: 5, + endRootIndex: 15, + key: "v6", + startCellId: "v6", + source: "custom", + }, + { + index: 2, + startRootIndex: 15, + endRootIndex: 30, + key: "v16", + startCellId: "v16", + source: "custom", + }, + ], + }, + ], + totalCells: 30, + cellsPerPage: 50, + }); + + const mockSubsectionsFromIndex = () => [ + makeSubsection("s-0", "1-5", "__start__", "auto", "v1"), + makeSubsection("s-1", "6-15", "v6", "custom", "v6"), + makeSubsection("s-2", "16-30", "v16", "custom", "v16", "Final"), + ]; + + it("shows remove button only for custom milestone subdivisions in source", async () => { + renderMilestoneAccordion({ + isSourceText: true, + milestoneIndex: createIndexWithSubdivisions(), + getSubsectionsForMilestone: vi.fn(() => mockSubsectionsFromIndex()), + }); + + const removeButtons = await screen.findAllByLabelText("Remove Subdivision Break"); + // Only the two "custom" subsections expose the delete control. + expect(removeButtons).toHaveLength(2); + }); + + it("does not show remove button on target documents", async () => { + renderMilestoneAccordion({ + isSourceText: false, + milestoneIndex: createIndexWithSubdivisions(), + getSubsectionsForMilestone: vi.fn(() => mockSubsectionsFromIndex()), + }); + + expect(screen.queryByLabelText("Remove Subdivision Break")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Reset Subdivisions")).not.toBeInTheDocument(); + }); + + it("posts updateMilestoneSubdivisions without the removed break", async () => { + renderMilestoneAccordion({ + isSourceText: true, + milestoneIndex: createIndexWithSubdivisions(), + getSubsectionsForMilestone: vi.fn(() => mockSubsectionsFromIndex()), + }); + + const removeButtons = await screen.findAllByLabelText("Remove Subdivision Break"); + // First one corresponds to the `v6` break. + await act(async () => { + fireEvent.click(removeButtons[0]); + }); + + expect(mockVscode.postMessage).toHaveBeenCalledWith({ + command: "updateMilestoneSubdivisions", + content: { + milestoneIndex: 0, + subdivisions: [{ startCellId: "v16" }], + }, + }); + }); + + it("reset requires two clicks and then posts an empty placement list", async () => { + renderMilestoneAccordion({ + isSourceText: true, + milestoneIndex: createIndexWithSubdivisions(), + getSubsectionsForMilestone: vi.fn(() => mockSubsectionsFromIndex()), + }); + + const resetButton = await screen.findByLabelText("Reset Subdivisions"); + await act(async () => { + fireEvent.click(resetButton); + }); + + // First click arms the confirmation but does not post. + const placementCalls = mockVscode.postMessage.mock.calls.filter( + (call: any[]) => call[0]?.command === "updateMilestoneSubdivisions" + ); + expect(placementCalls).toHaveLength(0); + + // After the click, the button's accessible label swaps to + // "Confirm Reset Subdivisions" to signal the armed state. + const confirmButton = await screen.findByLabelText("Confirm Reset Subdivisions"); + await act(async () => { + fireEvent.click(confirmButton); + }); + + expect(mockVscode.postMessage).toHaveBeenCalledWith({ + command: "updateMilestoneSubdivisions", + content: { + milestoneIndex: 0, + subdivisions: [], + }, + }); + }); + + it("reset button is hidden when no custom breaks exist", () => { + renderMilestoneAccordion({ + isSourceText: true, + milestoneIndex: { + milestones: [ + { + index: 0, + cellIndex: 0, + value: "Luke 1", + cellCount: 5, + subdivisions: [ + { + index: 0, + startRootIndex: 0, + endRootIndex: 5, + key: "__start__", + startCellId: "v1", + source: "auto", + }, + ], + }, + ], + totalCells: 5, + cellsPerPage: 50, + }, + getSubsectionsForMilestone: vi.fn(() => [ + makeSubsection("s-0", "1-5", "__start__", "auto", "v1"), + ]), + }); + + expect(screen.queryByLabelText("Reset Subdivisions")).not.toBeInTheDocument(); + }); + }); + + describe("Add Subdivision Break (source only)", () => { + const makeSubsection = ( + id: string, + label: string, + key: string, + source: "auto" | "custom", + endIndex: number, + startCellId?: string + ): Subsection => ({ + id, + label, + startIndex: 0, + endIndex, + key, + startCellId, + source, + }); + + const createSplittableIndex = (totalRootCells: number): MilestoneIndex => ({ + milestones: [ + { + index: 0, + cellIndex: 0, + value: "Luke 1", + cellCount: totalRootCells, + subdivisions: [ + { + index: 0, + startRootIndex: 0, + endRootIndex: totalRootCells, + key: "__start__", + startCellId: "v1", + source: "auto", + }, + ], + }, + ], + totalCells: totalRootCells, + cellsPerPage: 50, + }); + + const singleAutoSubsection = (endIndex: number) => [ + makeSubsection("s-0", `1-${endIndex}`, "__start__", "auto", endIndex, "v1"), + ]; + + it("shows the 'Add break…' button on source when the milestone has at least 2 cells", async () => { + renderMilestoneAccordion({ + isSourceText: true, + milestoneIndex: createSplittableIndex(10), + getSubsectionsForMilestone: vi.fn(() => singleAutoSubsection(10)), + }); + + const addBreakButton = await screen.findByLabelText("Add Subdivision Break"); + expect(addBreakButton).toBeInTheDocument(); + }); + + it("does not show the 'Add break…' button on target", () => { + renderMilestoneAccordion({ + isSourceText: false, + milestoneIndex: createSplittableIndex(10), + getSubsectionsForMilestone: vi.fn(() => singleAutoSubsection(10)), + }); + + expect(screen.queryByLabelText("Add Subdivision Break")).not.toBeInTheDocument(); + }); + + it("hides 'Add break…' when the milestone has only one cell (can't split)", () => { + renderMilestoneAccordion({ + isSourceText: true, + milestoneIndex: createSplittableIndex(1), + getSubsectionsForMilestone: vi.fn(() => singleAutoSubsection(1)), + }); + + expect(screen.queryByLabelText("Add Subdivision Break")).not.toBeInTheDocument(); + }); + + it("posts addMilestoneSubdivisionAnchor with the entered cellNumber", async () => { + renderMilestoneAccordion({ + isSourceText: true, + milestoneIndex: createSplittableIndex(10), + getSubsectionsForMilestone: vi.fn(() => singleAutoSubsection(10)), + }); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Add Subdivision Break")); + }); + + const input = await screen.findByLabelText("Cell number for new break"); + await act(async () => { + fireEvent.change(input, { target: { value: "5" } }); + }); + + // The submit button re-uses the "Add Subdivision Break" aria-label + // once the form is open (it IS the add action). + await act(async () => { + fireEvent.click(screen.getByLabelText("Add Subdivision Break")); + }); + + expect(mockVscode.postMessage).toHaveBeenCalledWith({ + command: "addMilestoneSubdivisionAnchor", + content: { + milestoneIndex: 0, + cellNumber: 5, + }, + }); + }); + + it("surfaces an inline error for out-of-range input and does not post", async () => { + renderMilestoneAccordion({ + isSourceText: true, + milestoneIndex: createSplittableIndex(10), + getSubsectionsForMilestone: vi.fn(() => singleAutoSubsection(10)), + }); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Add Subdivision Break")); + }); + + const input = await screen.findByLabelText("Cell number for new break"); + await act(async () => { + fireEvent.change(input, { target: { value: "99" } }); + }); + await act(async () => { + fireEvent.click(screen.getByLabelText("Add Subdivision Break")); + }); + + // Error text is announced via aria-live. + expect( + screen.getByText("Enter a number between 2 and 10.") + ).toBeInTheDocument(); + const placementCalls = mockVscode.postMessage.mock.calls.filter( + (call: any[]) => call[0]?.command === "addMilestoneSubdivisionAnchor" + ); + expect(placementCalls).toHaveLength(0); + }); + + it("does not post when submitting an empty cellNumber", async () => { + renderMilestoneAccordion({ + isSourceText: true, + milestoneIndex: createSplittableIndex(10), + getSubsectionsForMilestone: vi.fn(() => singleAutoSubsection(10)), + }); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Add Subdivision Break")); + }); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Add Subdivision Break")); + }); + + const placementCalls = mockVscode.postMessage.mock.calls.filter( + (call: any[]) => call[0]?.command === "addMilestoneSubdivisionAnchor" + ); + expect(placementCalls).toHaveLength(0); + }); + + it("respects useSubdivisionNumberLabels=true by showing numeric range instead of name", async () => { + const named: Subsection[] = [ + { + id: "s-0", + label: "1-5", + startIndex: 0, + endIndex: 5, + key: "__start__", + startCellId: "v1", + source: "auto", + name: "Genealogy", + }, + ]; + renderMilestoneAccordion({ + isSourceText: false, + milestoneIndex: createSplittableIndex(5), + getSubsectionsForMilestone: vi.fn(() => named), + useSubdivisionNumberLabels: true, + }); + + // Name is suppressed in favor of the numeric range; the name must + // NOT appear anywhere as the primary label. + expect(screen.queryByText("Genealogy")).not.toBeInTheDocument(); + expect(screen.getByText("1-5")).toBeInTheDocument(); + }); + + it("default behavior (useSubdivisionNumberLabels=false) shows the name", async () => { + const named: Subsection[] = [ + { + id: "s-0", + label: "1-5", + startIndex: 0, + endIndex: 5, + key: "__start__", + startCellId: "v1", + source: "auto", + name: "Genealogy", + }, + ]; + renderMilestoneAccordion({ + isSourceText: false, + milestoneIndex: createSplittableIndex(5), + getSubsectionsForMilestone: vi.fn(() => named), + }); + + expect(screen.getByText("Genealogy")).toBeInTheDocument(); + }); + + it("cancel button closes the form without posting", async () => { + renderMilestoneAccordion({ + isSourceText: true, + milestoneIndex: createSplittableIndex(10), + getSubsectionsForMilestone: vi.fn(() => singleAutoSubsection(10)), + }); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Add Subdivision Break")); + }); + + const input = await screen.findByLabelText("Cell number for new break"); + await act(async () => { + fireEvent.change(input, { target: { value: "5" } }); + }); + await act(async () => { + fireEvent.click(screen.getByLabelText("Cancel Add Break")); + }); + + // Form closed → input gone, trigger button restored. + expect( + screen.queryByLabelText("Cell number for new break") + ).not.toBeInTheDocument(); + expect(screen.getByLabelText("Add Subdivision Break")).toBeInTheDocument(); + const placementCalls = mockVscode.postMessage.mock.calls.filter( + (call: any[]) => call[0]?.command === "addMilestoneSubdivisionAnchor" + ); + expect(placementCalls).toHaveLength(0); + }); + }); + + describe("Settings mode (gear toggle)", () => { + it("hides edit affordances by default and reveals them after clicking the gear", async () => { + renderMilestoneAccordion({ initialSettingsMode: false }); + + // Default (read-only) state: gear is the only edit-related control + // in the header, every milestone's rename pencil is gone, and the + // per-subdivision pencils + add-break / reset footers stay hidden. + // Use queryAllByLabelText so the assertion is precise about the + // *count* of pencils (zero) without throwing when there happen + // to be multiple rows. + expect(screen.queryAllByLabelText("Rename Milestone")).toHaveLength(0); + expect( + screen.queryAllByLabelText("Rename Milestone Subdivision") + ).toHaveLength(0); + expect(screen.queryAllByLabelText("Add Subdivision Break")).toHaveLength(0); + const gearButton = screen.getByLabelText("Toggle Milestone Settings"); + expect(gearButton).toHaveAttribute("aria-pressed", "false"); + + await act(async () => { + fireEvent.click(gearButton); + }); + + // Settings on → every milestone row exposes its rename pencil. + // We assert the current row's pencil specifically (the others + // mirror it) so the test reads as "pencil for Chapter 1 is back". + expect(getRenameMilestoneButton()).toBeInTheDocument(); + expect(screen.getByLabelText("Toggle Milestone Settings")).toHaveAttribute( + "aria-pressed", + "true" + ); + }); + + it("reveals per-milestone-subdivision rename pencils when settings mode is open", async () => { + // Milestone subdivisions need a `key` for the rename pencil to + // render at all (per the canRename guard); supply one so we can + // verify the gear toggle uncovers them. + renderMilestoneAccordion({ + initialSettingsMode: false, + getSubsectionsForMilestone: vi.fn(() => [ + { + id: "sub-0-1", + label: "1–5", + startIndex: 0, + endIndex: 5, + key: "__start__", + } as Subsection, + ]), + }); + + expect(screen.queryByLabelText("Rename Milestone Subdivision")).not.toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Toggle Milestone Settings")); + }); + + const renamePencils = screen.getAllByLabelText("Rename Milestone Subdivision"); + expect(renamePencils.length).toBeGreaterThan(0); + }); + + it("shows the Add Subdivision Break footer only on source documents in settings mode", async () => { + renderMilestoneAccordion({ + initialSettingsMode: false, + isSourceText: true, + }); + + expect(screen.queryByLabelText("Add Subdivision Break")).not.toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Toggle Milestone Settings")); + }); + + // Once the gear opens settings, source-only "Add break…" buttons + // become reachable. (Target documents never see these regardless + // of the gear; that's covered by the existing "Add Break — target" + // tests.) + expect(screen.getAllByLabelText("Add Subdivision Break").length).toBeGreaterThan(0); + }); + + it("collapses settings mode again when the accordion is closed and reopened", async () => { + const { rerender } = renderMilestoneAccordion({ initialSettingsMode: false }); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Toggle Milestone Settings")); + }); + expect(getRenameMilestoneButton()).toBeInTheDocument(); + + // Close and reopen — the user should land back in read-only mode + // so an accidental click on the gear isn't sticky across sessions. + await act(async () => { + rerender( + + ); + }); + await act(async () => { + rerender( + + ); + }); + + // After remount in read-only mode, NO milestone exposes a rename + // pencil — the gear must collapse settings rather than persist + // through an accordion close/reopen cycle. + expect(screen.queryAllByLabelText("Rename Milestone")).toHaveLength(0); + expect(screen.getByLabelText("Toggle Milestone Settings")).toHaveAttribute( + "aria-pressed", + "false" + ); + }); + }); + + describe("Milestone Rename - Accordion Close (no refresh on close)", () => { it("should not send refreshWebviewAfterMilestoneEdits when accordion closes after saving (provider pushes updates immediately on save)", async () => { const { rerender } = renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -772,7 +1619,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "New Value" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -822,4 +1669,217 @@ describe("MilestoneAccordion - Milestone Editing", () => { expect(refreshCalls).toHaveLength(0); }); }); + + // ---------------------------------------------------------------- + // Milestone-placement editing controls (gated by the workspace + // setting + isSourceText + settings mode). Hidden by default — when + // the controls are reachable they post the new structural commands + // (`addMilestoneAtCell`, `removeMilestone`, + // `promoteSubdivisionToMilestone`, `demoteMilestoneToSubdivision`). + // ---------------------------------------------------------------- + describe("Milestone Placement Editing", () => { + it("hides Add Milestone, demote, remove, and promote controls when the setting is off", () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: false, + getSubsectionsForMilestone: vi.fn(() => [ + { + id: "sub-0-1", + label: "1–5", + startIndex: 0, + endIndex: 5, + key: "__start__", + source: "auto", + } as Subsection, + { + id: "sub-0-2", + label: "6–10", + startIndex: 5, + endIndex: 10, + key: "v6", + startCellId: "v6", + source: "custom", + } as Subsection, + ]), + }); + + expect(screen.queryByLabelText("Add Milestone")).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Promote Subdivision to Milestone") + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Demote Milestone to Subdivision") + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Remove Milestone")).not.toBeInTheDocument(); + }); + + it("shows the Add Milestone button when the feature is on, source, in settings mode", () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + }); + expect(screen.getAllByLabelText("Add Milestone").length).toBeGreaterThan(0); + }); + + it("posts addMilestoneAtCell with the entered cell number", async () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + }); + + // Open the milestone form on the first milestone. + const openButtons = screen.getAllByLabelText("Add Milestone"); + await act(async () => { + fireEvent.click(openButtons[0]); + }); + + const input = screen.getByLabelText("Cell number for new milestone"); + await act(async () => { + fireEvent.change(input, { target: { value: "3" } }); + }); + + const submitButtons = screen.getAllByLabelText("Add Milestone"); + // After opening the form there are two "Add Milestone" buttons: + // the open trigger on other milestones + the submit button on + // this one. The submit one is type=submit. + const submit = submitButtons.find( + (b) => (b as HTMLButtonElement).type === "submit" + ) as HTMLButtonElement; + expect(submit).toBeTruthy(); + await act(async () => { + fireEvent.click(submit); + }); + + const calls = mockVscode.postMessage.mock.calls.filter( + (c: any[]) => c[0]?.command === "addMilestoneAtCell" + ); + expect(calls).toHaveLength(1); + expect(calls[0][0].content).toEqual({ milestoneIndex: 0, cellNumber: 3 }); + }); + + it("posts promoteSubdivisionToMilestone when the promote icon is clicked on a custom subdivision", async () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + getSubsectionsForMilestone: vi.fn(() => [ + { + id: "sub-0-1", + label: "1–5", + startIndex: 0, + endIndex: 5, + key: "__start__", + source: "auto", + } as Subsection, + { + id: "sub-0-2", + label: "6–10", + startIndex: 5, + endIndex: 10, + key: "v6", + startCellId: "v6", + source: "custom", + } as Subsection, + ]), + }); + + const promoteButton = screen.getAllByLabelText( + "Promote Subdivision to Milestone" + )[0]; + await act(async () => { + fireEvent.click(promoteButton); + }); + + const calls = mockVscode.postMessage.mock.calls.filter( + (c: any[]) => c[0]?.command === "promoteSubdivisionToMilestone" + ); + expect(calls).toHaveLength(1); + expect(calls[0][0].content.subdivisionKey).toBe("v6"); + }); + + it("first milestone never exposes remove or demote buttons", () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + milestoneIndex: createMockMilestoneIndex([ + { value: "Chapter 1", index: 0 }, + ]), + }); + expect(screen.queryByLabelText("Remove Milestone")).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Demote Milestone to Subdivision") + ).not.toBeInTheDocument(); + }); + + it("requires two clicks on Remove before posting removeMilestone", async () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + }); + + const removeButtons = screen.getAllByLabelText("Remove Milestone"); + // First click arms the action — no message yet. + await act(async () => { + fireEvent.click(removeButtons[0]); + }); + let calls = mockVscode.postMessage.mock.calls.filter( + (c: any[]) => c[0]?.command === "removeMilestone" + ); + expect(calls).toHaveLength(0); + + // Second click commits. + const armed = screen.getByLabelText("Confirm Remove Milestone"); + await act(async () => { + fireEvent.click(armed); + }); + calls = mockVscode.postMessage.mock.calls.filter( + (c: any[]) => c[0]?.command === "removeMilestone" + ); + expect(calls).toHaveLength(1); + expect(calls[0][0].content).toEqual({ milestoneIndex: 1 }); + }); + + it("posts demoteMilestoneToSubdivision on a single click", async () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + }); + + const demoteButtons = screen.getAllByLabelText( + "Demote Milestone to Subdivision" + ); + await act(async () => { + fireEvent.click(demoteButtons[0]); + }); + const calls = mockVscode.postMessage.mock.calls.filter( + (c: any[]) => c[0]?.command === "demoteMilestoneToSubdivision" + ); + expect(calls).toHaveLength(1); + expect(calls[0][0].content).toEqual({ milestoneIndex: 1 }); + // Confirm there's no two-click "Confirm Demote Milestone" arming + // step left over from the previous behavior. + expect( + screen.queryByLabelText("Confirm Demote Milestone") + ).not.toBeInTheDocument(); + }); + + it("does not render placement controls on target documents", () => { + renderMilestoneAccordion({ + isSourceText: false, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + }); + expect(screen.queryByLabelText("Add Milestone")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Remove Milestone")).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Demote Milestone to Subdivision") + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx index 5bd7c604f..5c317fc05 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -10,7 +10,17 @@ import { import { ProgressDots } from "./ProgressDots"; import { deriveSubsectionPercentages, getProgressDisplay } from "../utils/progressUtils"; import MicrophoneIcon from "../../components/ui/icons/MicrophoneIcon"; -import { Languages, Check, RotateCcw } from "lucide-react"; +import { + Languages, + Check, + RotateCcw, + X, + Undo2, + Plus, + Trash2, + ArrowUpFromLine, + ArrowDownToLine, +} from "lucide-react"; import type { Subsection, ProgressPercentages } from "../../lib/types"; import type { MilestoneIndex, MilestoneInfo } from "../../../../../types"; import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; @@ -46,6 +56,28 @@ interface MilestoneAccordionProps { }; requestSubsectionProgress?: (milestoneIdx: number) => void; vscode: any; + /** + * When true, display the numeric cell range on every subdivision even if + * the subdivision has a user-assigned name. Renaming and editing still + * work normally — the preference only affects the visible label. Defaults + * to false (names take precedence). + */ + useSubdivisionNumberLabels?: boolean; + /** + * When true, the accordion mounts with the gear/settings affordances + * already revealed (title pencil, per-subsection pencils always visible, + * "Add break…" / "Reset" footer controls visible). Useful for tests and + * for parents that want to deep-link straight into editing. Defaults to + * `false`, matching the read-only default UX. + */ + initialSettingsMode?: boolean; + /** + * Workspace opt-in for milestone-placement editing controls + * (add/remove/promote/demote). When false the structural buttons are + * hidden even in settings mode. Mirrors + * `codex-editor-extension.enableMilestonePlacementEditing`. + */ + enableMilestonePlacementEditing?: boolean; } export function MilestoneAccordion({ @@ -63,6 +95,9 @@ export function MilestoneAccordion({ calculateSubsectionProgress, requestSubsectionProgress, vscode, + useSubdivisionNumberLabels = false, + initialSettingsMode = false, + enableMilestonePlacementEditing = false, }: MilestoneAccordionProps) { // Layout constants const DROPDOWN_MAX_HEIGHT_VIEWPORT_PERCENT = 60; // 60vh @@ -85,13 +120,381 @@ export function MilestoneAccordion({ const [expandedMilestone, setExpandedMilestone] = useState( currentMilestoneIndex.toString() ); - const [isEditingMilestone, setIsEditingMilestone] = useState(false); + // Per-row milestone rename. The input lives inline on the milestone row + // (parity with the subsection rename pencil), so we track WHICH milestone + // index is in edit mode rather than a global boolean. `null` = not editing. + const [editingMilestoneIdx, setEditingMilestoneIdx] = useState(null); const [editedMilestoneValue, setEditedMilestoneValue] = useState(""); const [originalMilestoneValue, setOriginalMilestoneValue] = useState(""); const inputRef = useRef(null); + const isEditingMilestone = editingMilestoneIdx !== null; + // Settings mode reveals destructive / structural controls (title pencil, + // per-subsection pencils, add-break / reset footers). Default off so the + // accordion stays read-only on first open; the gear button toggles it. + const [isSettingsMode, setIsSettingsMode] = useState(initialSettingsMode); // Local cache of edited milestone values to show changes immediately before webview refresh const [localMilestoneValues, setLocalMilestoneValues] = useState>({}); + // Subsection rename state. `editingSubsection` identifies the single row + // currently in edit mode; `localSubsectionNames` is an optimistic cache so + // saved renames render immediately without waiting for the webview refresh. + // Keyed by `${milestoneIdx}:${subsectionKey}` so renames survive milestone + // expansion/collapse. + const [editingSubsection, setEditingSubsection] = useState<{ + milestoneIdx: number; + subsectionIdx: number; + key: string; + } | null>(null); + const [editedSubsectionName, setEditedSubsectionName] = useState(""); + const [originalSubsectionName, setOriginalSubsectionName] = useState(""); + const [localSubsectionNames, setLocalSubsectionNames] = useState>({}); + const subsectionInputRef = useRef(null); + + const getLocalSubsectionName = ( + milestoneIdx: number, + key: string | undefined + ): string | undefined => { + if (!key) return undefined; + return localSubsectionNames[`${milestoneIdx}:${key}`]; + }; + + // Tracks the milestone whose "Reset breaks" button is in its confirm + // state (the one-click→confirm pattern). Null means no reset is pending. + const [resetConfirmMilestoneIdx, setResetConfirmMilestoneIdx] = useState(null); + const resetConfirmTimeoutRef = useRef(null); + + // "Add break" form state. Only one milestone can have the form open at a + // time; the cell-number field is a string so we can accept and validate + // partial input (empty, non-numeric, out of range) before posting. + const [addBreakMilestoneIdx, setAddBreakMilestoneIdx] = useState(null); + const [addBreakCellNumber, setAddBreakCellNumber] = useState(""); + const [addBreakError, setAddBreakError] = useState(""); + const addBreakInputRef = useRef(null); + + // "Add milestone" form state — independent from the subdivision form so + // the user can switch between the two without the second form forgetting + // the value they typed. Same string-typed input pattern. + const [addMilestoneMilestoneIdx, setAddMilestoneMilestoneIdx] = useState(null); + const [addMilestoneCellNumber, setAddMilestoneCellNumber] = useState(""); + const [addMilestoneError, setAddMilestoneError] = useState(""); + const addMilestoneInputRef = useRef(null); + + // Two-click confirmation for the milestone trash. Demote is reversible + // (you can promote back) so it commits on a single click; remove drops + // the seam entirely, so it stays gated on the arm-then-confirm pattern. + const [removeConfirmMilestoneIdx, setRemoveConfirmMilestoneIdx] = useState(null); + const removeConfirmTimeoutRef = useRef(null); + + useEffect(() => { + const resetTimer = resetConfirmTimeoutRef; + const removeTimer = removeConfirmTimeoutRef; + return () => { + if (resetTimer.current !== null) { + window.clearTimeout(resetTimer.current); + } + if (removeTimer.current !== null) { + window.clearTimeout(removeTimer.current); + } + }; + }, []); + + // When the add-break form opens, focus the number input so keyboard-first + // users can type immediately. + useEffect(() => { + if (addBreakMilestoneIdx !== null) { + addBreakInputRef.current?.focus(); + } + }, [addBreakMilestoneIdx]); + + useEffect(() => { + if (addMilestoneMilestoneIdx !== null) { + addMilestoneInputRef.current?.focus(); + } + }, [addMilestoneMilestoneIdx]); + + /** + * Rebuilds the milestone's placement list from its resolved subdivisions. + * Only subdivisions at index > 0 with `source === "custom"` and a valid + * `startCellId` correspond to actual placements; the implicit first + * subdivision and arithmetic auto-chunks are derived, not stored. + */ + const getCurrentPlacements = ( + milestone: MilestoneInfo | undefined + ): { startCellId: string }[] => { + if (!milestone?.subdivisions) return []; + return milestone.subdivisions + .filter((s) => s.index > 0 && s.source === "custom" && !!s.startCellId) + .map((s) => ({ startCellId: s.startCellId as string })); + }; + + const handleDeleteSubsection = ( + e: React.MouseEvent, + milestoneIdx: number, + subsection: Subsection + ) => { + e.stopPropagation(); + if (!isSourceText) return; // Defensive: control should only render on source. + if (!subsection.startCellId || subsection.source !== "custom") return; + const milestone = milestoneIndex?.milestones[milestoneIdx]; + const placements = getCurrentPlacements(milestone).filter( + (p) => p.startCellId !== subsection.startCellId + ); + vscode.postMessage({ + command: "updateMilestoneSubdivisions", + content: { + milestoneIndex: milestoneIdx, + subdivisions: placements, + }, + }); + }; + + const handleResetSubdivisionsClick = ( + e: React.MouseEvent, + milestoneIdx: number + ) => { + e.stopPropagation(); + if (!isSourceText) return; + if (resetConfirmMilestoneIdx !== milestoneIdx) { + // First click → arm the confirmation; auto-disarm after a short + // window so the button never stays "hot" forever. + setResetConfirmMilestoneIdx(milestoneIdx); + if (resetConfirmTimeoutRef.current !== null) { + window.clearTimeout(resetConfirmTimeoutRef.current); + } + resetConfirmTimeoutRef.current = window.setTimeout(() => { + setResetConfirmMilestoneIdx(null); + resetConfirmTimeoutRef.current = null; + }, 3000); + return; + } + // Second click → commit the reset and clear the armed state. + if (resetConfirmTimeoutRef.current !== null) { + window.clearTimeout(resetConfirmTimeoutRef.current); + resetConfirmTimeoutRef.current = null; + } + setResetConfirmMilestoneIdx(null); + vscode.postMessage({ + command: "updateMilestoneSubdivisions", + content: { + milestoneIndex: milestoneIdx, + subdivisions: [], + }, + }); + }; + + /** + * Largest valid `cellNumber` for an add-break request in the given + * milestone. Valid range is [2, totalRootCells]; we derive the upper + * bound from the last resolved subsection's `endIndex` (which is a root + * index, matching `SubdivisionInfo.endRootIndex` one-to-one). + */ + const getMaxCellNumberForMilestone = (subsections: Subsection[]): number => { + if (!subsections.length) return 0; + return subsections[subsections.length - 1].endIndex; + }; + + const handleOpenAddBreak = (e: React.MouseEvent, milestoneIdx: number) => { + e.stopPropagation(); + if (!isSourceText) return; + setAddBreakMilestoneIdx(milestoneIdx); + setAddBreakCellNumber(""); + setAddBreakError(""); + }; + + const handleCancelAddBreak = (e?: React.MouseEvent) => { + e?.stopPropagation(); + setAddBreakMilestoneIdx(null); + setAddBreakCellNumber(""); + setAddBreakError(""); + }; + + const handleSubmitAddBreak = ( + e: React.MouseEvent | React.FormEvent, + milestoneIdx: number, + maxCellNumber: number + ) => { + e.preventDefault(); + e.stopPropagation(); + if (!isSourceText) return; + const trimmed = addBreakCellNumber.trim(); + const parsed = Number(trimmed); + // Allowed range mirrors the provider: splitting at cell 1 would + // duplicate the implicit first subdivision, and we can't split + // beyond the last cell. + if ( + trimmed.length === 0 || + !Number.isFinite(parsed) || + !Number.isInteger(parsed) || + parsed < 2 || + parsed > maxCellNumber + ) { + setAddBreakError( + maxCellNumber >= 2 + ? `Enter a number between 2 and ${maxCellNumber}.` + : "This milestone is too short to split." + ); + return; + } + vscode.postMessage({ + command: "addMilestoneSubdivisionAnchor", + content: { + milestoneIndex: milestoneIdx, + cellNumber: parsed, + }, + }); + handleCancelAddBreak(); + }; + + const handleOpenAddMilestone = ( + e: React.MouseEvent, + milestoneIdx: number + ) => { + e.stopPropagation(); + if (!isSourceText) return; + setAddMilestoneMilestoneIdx(milestoneIdx); + setAddMilestoneCellNumber(""); + setAddMilestoneError(""); + // Close the sibling subdivision form so only one is on screen at a + // time — keeps the layout calm and the focus path predictable. + setAddBreakMilestoneIdx(null); + }; + + const handleCancelAddMilestone = (e?: React.MouseEvent) => { + e?.stopPropagation(); + setAddMilestoneMilestoneIdx(null); + setAddMilestoneCellNumber(""); + setAddMilestoneError(""); + }; + + const handleSubmitAddMilestone = ( + e: React.MouseEvent | React.FormEvent, + milestoneIdx: number, + maxCellNumber: number + ) => { + e.preventDefault(); + e.stopPropagation(); + if (!isSourceText) return; + const trimmed = addMilestoneCellNumber.trim(); + const parsed = Number(trimmed); + if ( + trimmed.length === 0 || + !Number.isFinite(parsed) || + !Number.isInteger(parsed) || + parsed < 2 || + parsed > maxCellNumber + ) { + setAddMilestoneError( + maxCellNumber >= 2 + ? `Enter a number between 2 and ${maxCellNumber}.` + : "This milestone is too short to split." + ); + return; + } + vscode.postMessage({ + command: "addMilestoneAtCell", + content: { + milestoneIndex: milestoneIdx, + cellNumber: parsed, + }, + }); + handleCancelAddMilestone(); + }; + + const handlePromoteSubdivision = ( + e: React.MouseEvent, + milestoneIdx: number, + subsection: Subsection + ) => { + e.stopPropagation(); + if (!isSourceText) return; + if (!enableMilestonePlacementEditing) return; + if (!subsection.startCellId || subsection.source !== "custom") return; + // The implicit first subdivision shares its key with the milestone + // start; promoting it would create an empty milestone and drop the + // boundary anchor. Surface this defensively even though the button + // is hidden in the UI for the first subdivision. + if (subsection.startIndex === 0) return; + vscode.postMessage({ + command: "promoteSubdivisionToMilestone", + content: { + milestoneIndex: milestoneIdx, + subdivisionKey: subsection.startCellId, + }, + }); + }; + + /** + * Two-click confirmation pattern shared with `handleResetSubdivisionsClick`: + * first click arms the action with a 3-second auto-disarm window; second + * click within the window commits. + */ + const armOrCommit = ( + milestoneIdx: number, + currentArmed: number | null, + setArmed: (idx: number | null) => void, + timeoutRef: React.MutableRefObject, + commit: () => void + ): boolean => { + if (currentArmed !== milestoneIdx) { + setArmed(milestoneIdx); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => { + setArmed(null); + timeoutRef.current = null; + }, 3000); + return false; + } + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setArmed(null); + commit(); + return true; + }; + + const handleRemoveMilestoneClick = ( + e: React.MouseEvent, + milestoneIdx: number + ) => { + e.stopPropagation(); + if (!isSourceText) return; + if (!enableMilestonePlacementEditing) return; + if (milestoneIdx === 0) return; + armOrCommit( + milestoneIdx, + removeConfirmMilestoneIdx, + setRemoveConfirmMilestoneIdx, + removeConfirmTimeoutRef, + () => { + vscode.postMessage({ + command: "removeMilestone", + content: { milestoneIndex: milestoneIdx }, + }); + } + ); + }; + + const handleDemoteMilestoneClick = ( + e: React.MouseEvent, + milestoneIdx: number + ) => { + e.stopPropagation(); + if (!isSourceText) return; + if (!enableMilestonePlacementEditing) return; + if (milestoneIdx === 0) return; + // Demote commits on a single click — it's reversible (promote back to + // a milestone) and only flips an existing milestone's role to a + // subdivision break. Pure remove keeps the two-click confirmation + // since it drops the seam entirely. + vscode.postMessage({ + command: "demoteMilestoneToSubdivision", + content: { milestoneIndex: milestoneIdx }, + }); + }; + // Calculate position and dimensions const calculatePositionAndDimensions = () => { if (isOpen && anchorRef.current) { @@ -167,40 +570,50 @@ export function MilestoneAccordion({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); - // Focus trap and ESC key handling + // Auto-focus the accordion wrapper ONCE on each open transition. Splitting + // this out from the ESC / click-outside listeners is critical: when those + // listeners' deps (notably `onClose`, often an unstable inline arrow from + // the parent) churn, the combined effect re-fires and steals focus from + // any in-progress inline rename input (subdivision pencil edits especially + // — the milestone rename input lives inside an AccordionTrigger that has + // its own focus management so it's less affected). + const wasOpenRef = useRef(false); useEffect(() => { - if (isOpen && accordionRef.current) { - // Auto-focus the accordion when opened + if (isOpen && !wasOpenRef.current && accordionRef.current) { accordionRef.current.focus(); + } + wasOpenRef.current = isOpen; + }, [isOpen]); - // Handle ESC key press - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; + // ESC + click-outside listeners. These re-attach when `onClose` changes + // reference (cheap), but never touch focus, so inline renames stay sticky. + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; - document.addEventListener("keydown", handleKeyDown); - - // Close when clicking outside - const handleClickOutside = (e: MouseEvent) => { - if ( - accordionRef.current && - !accordionRef.current.contains(e.target as Node) && - anchorRef.current && - !anchorRef.current.contains(e.target as Node) - ) { - onClose(); - } - }; + document.addEventListener("keydown", handleKeyDown); - document.addEventListener("mousedown", handleClickOutside); + const handleClickOutside = (e: MouseEvent) => { + if ( + accordionRef.current && + !accordionRef.current.contains(e.target as Node) && + anchorRef.current && + !anchorRef.current.contains(e.target as Node) + ) { + onClose(); + } + }; - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("mousedown", handleClickOutside); - }; - } + document.addEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("mousedown", handleClickOutside); + }; }, [isOpen, onClose, anchorRef]); // Sync expanded milestone state when accordion opens @@ -213,8 +626,15 @@ export function MilestoneAccordion({ // Reset editing state when accordion closes useEffect(() => { if (!isOpen) { - setIsEditingMilestone(false); + setEditingMilestoneIdx(null); + // Also collapse the gear/settings affordances so reopening the + // accordion always starts from the read-only baseline (matches + // initialSettingsMode default and avoids "stuck open" surprises). + setIsSettingsMode(initialSettingsMode); } + // We intentionally only re-run on `isOpen` changes; resetting on + // initialSettingsMode flips would surprise live edits. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); // Clear local cache when milestoneIndex prop changes (after webview refresh) @@ -500,110 +920,145 @@ export function MilestoneAccordion({ return milestone?.value || ""; }; - const handleEditMilestoneClick = (e: React.MouseEvent) => { + const beginEditMilestone = (e: React.MouseEvent, milestoneIdx: number): void => { e.stopPropagation(); - const displayedMilestone = getDisplayedMilestone(); - if (displayedMilestone) { - setOriginalMilestoneValue(displayedMilestone.value); - setEditedMilestoneValue(displayedMilestone.value); - setIsEditingMilestone(true); - // Focus the input after state update - setTimeout(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, 0); - } + const milestone = milestoneIndex?.milestones[milestoneIdx]; + if (!milestone) return; + + const displayValue = localMilestoneValues[milestoneIdx] || milestone.value; + setOriginalMilestoneValue(displayValue); + setEditedMilestoneValue(displayValue); + setEditingMilestoneIdx(milestoneIdx); + + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); }; - const handleSaveMilestone = (e: React.MouseEvent) => { + const handleSaveMilestone = (e: React.MouseEvent | React.KeyboardEvent) => { e.stopPropagation(); - const displayedIndex = getDisplayedMilestoneIndex(); + const targetIdx = editingMilestoneIdx; + if (targetIdx === null) return; const trimmedValue = editedMilestoneValue.trim(); - const displayedMilestone = getDisplayedMilestone(); + const targetMilestone = milestoneIndex?.milestones[targetIdx]; if ( - displayedMilestone && + targetMilestone && trimmedValue !== "" && - trimmedValue !== displayedMilestone.value + trimmedValue !== targetMilestone.value ) { - // Validate that the milestone index is still valid before sending - if (displayedIndex < 0 || displayedIndex >= (milestoneIndex?.milestones.length || 0)) { + if (targetIdx < 0 || targetIdx >= (milestoneIndex?.milestones.length || 0)) { console.error( - `[handleSaveMilestone] Invalid milestone index: ${displayedIndex}, total milestones: ${ + `[handleSaveMilestone] Invalid milestone index: ${targetIdx}, total milestones: ${ milestoneIndex?.milestones.length || 0 }` ); return; } - // Send message to update milestone value; provider pushes updated data to webview immediately vscode.postMessage({ command: "updateMilestoneValue", content: { - milestoneIndex: displayedIndex, + milestoneIndex: targetIdx, newValue: trimmedValue, }, }); - // Update the original value to the new saved value so the checkmark state is correct setOriginalMilestoneValue(trimmedValue); - // Update local cache immediately so the accordion shows the change before webview refresh setLocalMilestoneValues((prev) => ({ ...prev, - [displayedIndex]: trimmedValue, + [targetIdx]: trimmedValue, })); } - // Keep the accordion open and exit edit mode to show the saved result - setIsEditingMilestone(false); + setEditingMilestoneIdx(null); }; - const handleRevertMilestone = (e: React.MouseEvent) => { + const handleRevertMilestone = (e: React.MouseEvent | React.KeyboardEvent) => { e.stopPropagation(); - // Revert the value and close the edit field setEditedMilestoneValue(originalMilestoneValue); - setIsEditingMilestone(false); + setEditingMilestoneIdx(null); }; const handleInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); - handleSaveMilestone(e as any); + handleSaveMilestone(e); } else if (e.key === "Escape") { e.preventDefault(); - // Escape should exit edit mode and revert - setEditedMilestoneValue(originalMilestoneValue); - setIsEditingMilestone(false); + handleRevertMilestone(e); } }; - // Handle milestone expansion - if editing, switch to editing the new milestone - const handleMilestoneExpansion = (value: string | null) => { - // Update expanded milestone first - setExpandedMilestone(value); + const handleSubsectionEditClick = ( + e: React.MouseEvent, + milestoneIdx: number, + subsectionIdx: number, + subsection: Subsection + ) => { + e.stopPropagation(); + if (!subsection.key) return; + const currentName = + getLocalSubsectionName(milestoneIdx, subsection.key) ?? subsection.name ?? ""; + setEditingSubsection({ milestoneIdx, subsectionIdx, key: subsection.key }); + setOriginalSubsectionName(currentName); + setEditedSubsectionName(currentName); + setTimeout(() => { + subsectionInputRef.current?.focus(); + subsectionInputRef.current?.select(); + }, 0); + }; - if (isEditingMilestone && value !== null) { - // User clicked on another milestone while editing - switch to editing that milestone - const newMilestoneIndex = parseInt(value); - if (!isNaN(newMilestoneIndex) && milestoneIndex?.milestones[newMilestoneIndex]) { - // Use getDisplayedMilestone to get the value (which includes local cache) - const displayedIndex = newMilestoneIndex; - const milestone = milestoneIndex.milestones[displayedIndex]; - if (milestone) { - // Use cached value if available, otherwise use prop value - const displayValue = localMilestoneValues[displayedIndex] || milestone.value; - setOriginalMilestoneValue(displayValue); - setEditedMilestoneValue(displayValue); - // Keep edit mode open and focus the input - setTimeout(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, 0); - } - } + const handleSaveSubsectionName = ( + e: React.MouseEvent | React.KeyboardEvent + ) => { + e.stopPropagation(); + if (!editingSubsection) return; + const trimmed = editedSubsectionName.trim(); + if (trimmed !== originalSubsectionName.trim()) { + vscode.postMessage({ + command: "updateMilestoneSubdivisionName", + content: { + milestoneIndex: editingSubsection.milestoneIdx, + subdivisionKey: editingSubsection.key, + newName: trimmed, + }, + }); + // Optimistic cache so the UI reflects the new (or cleared) name + // before the provider refresh arrives. + setLocalSubsectionNames((prev) => ({ + ...prev, + [`${editingSubsection.milestoneIdx}:${editingSubsection.key}`]: trimmed, + })); + } + setEditingSubsection(null); + }; + + const handleRevertSubsectionName = ( + e: React.MouseEvent | React.KeyboardEvent + ) => { + e.stopPropagation(); + setEditingSubsection(null); + }; + + const handleSubsectionInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSaveSubsectionName(e); + } else if (e.key === "Escape") { + e.preventDefault(); + handleRevertSubsectionName(e); } }; + // Handle milestone expansion. Rename now lives inline on each row so we no + // longer need to follow the user's selection — the input stays anchored to + // the milestone it was opened on. + const handleMilestoneExpansion = (value: string | null) => { + setExpandedMilestone(value); + }; + return (
- {isEditingMilestone ? ( - setEditedMilestoneValue(e.target.value)} - onKeyDown={handleInputKeyDown} - className="text-lg font-semibold m-0 bg-transparent border border-[var(--vscode-input-border)] rounded px-2 py-1 flex-1 mr-2 focus:outline-none focus:ring-2 focus:ring-[var(--vscode-focusBorder)]" - style={{ - color: "var(--vscode-input-foreground)", - }} - /> - ) : ( -

{getDisplayedMilestoneValue()}

- )} +

{getDisplayedMilestoneValue()}

- {isEditingMilestone ? ( - <> - - - - - - - - ) : ( - - - - )} + { + e.stopPropagation(); + setIsSettingsMode((prev) => !prev); + }} + aria-pressed={isSettingsMode} + > + +
= 100; + const isEditingThisMilestone = + editingMilestoneIdx === milestoneIdx; + return (
- - {displayValue} - + {isEditingThisMilestone ? ( + + setEditedMilestoneValue( + e.target.value + ) + } + onKeyDown={handleInputKeyDown} + onClick={(e) => e.stopPropagation()} + className="font-medium flex-1 mr-2 bg-transparent border border-[var(--vscode-input-border)] rounded px-2 py-0.5 focus:outline-none focus:ring-2 focus:ring-[var(--vscode-focusBorder)]" + style={{ + color: "var(--vscode-input-foreground)", + }} + /> + ) : ( + + {displayValue} + + )}
+ {isEditingThisMilestone ? ( + <> + + + + + + + + ) : ( + isSettingsMode && ( + <> + + beginEditMilestone( + e, + milestoneIdx + ) + } + > + + + {enableMilestonePlacementEditing && + isSourceText && + milestoneIdx > 0 ? ( + <> + + handleDemoteMilestoneClick( + e, + milestoneIdx + ) + } + > + + + + handleRemoveMilestoneClick( + e, + milestoneIdx + ) + } + className={ + removeConfirmMilestoneIdx === + milestoneIdx + ? "bg-inputValidation-warningBackground" + : undefined + } + > + + + + ) : ( + /* Greyed-out ghost trash — keeps the + row spacing identical for the + first milestone (which can never + be removed) and when the feature + flag is off / on a target file. */ + + )} + + ) + )}
+ onClick={() => { + if (isEditingThisRow) return; handleSubsectionClick( milestoneIdx, subsectionIdx - ) - } - className={`flex items-center justify-between pr-3 pl-6 py-2 rounded-md cursor-pointer transition-colors ${ - isActive - ? "bg-accent font-semibold" + ); + }} + className={`group flex items-center justify-between pr-3 pl-6 py-2 rounded-md transition-colors ${ + isEditingThisRow + ? "bg-secondary" + : isActive + ? "bg-accent font-semibold cursor-pointer" : unsavedChanges ? "opacity-60 cursor-not-allowed" - : "hover:bg-secondary" + : "hover:bg-secondary cursor-pointer" }`} > - {subsection.label} - + {isEditingThisRow ? ( + + setEditedSubsectionName( + e.target.value + ) + } + onKeyDown={ + handleSubsectionInputKeyDown + } + onClick={(e) => + e.stopPropagation() + } + placeholder={subsection.label} + className="flex-1 mr-2 bg-transparent border border-[var(--vscode-input-border)] rounded px-2 py-0.5 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--vscode-focusBorder)]" + style={{ + color: "var(--vscode-input-foreground)", + }} + /> + ) : ( + + + {displayName || + subsection.label} + + {displayName && ( + + {subsection.label} + + )} + + )} +
+ {isEditingThisRow ? ( + <> + + + + + + + + ) : ( + <> + {/* Per-subsection edit affordances live behind the + gear/settings toggle. When off, neither the rename + pencil nor the remove "X" should be reachable, so we + don't render them at all (avoids tab-stops and stale + tooltips). When on, they're always visible — no more + hover-only reveal. */} + {isSettingsMode && + canRename && ( + + handleSubsectionEditClick( + e, + milestoneIdx, + subsectionIdx, + subsection + ) + } + > + + + )} + {isSettingsMode && + enableMilestonePlacementEditing && + isSourceText && + subsection.source === + "custom" && + subsection.startCellId && + subsection.startIndex > + 0 && ( + + handlePromoteSubdivision( + e, + milestoneIdx, + subsection + ) + } + > + + + )} + {isSettingsMode && + (isSourceText && + subsection.source === + "custom" && + subsection.startCellId ? ( + + handleDeleteSubsection( + e, + milestoneIdx, + subsection + ) + } + > + + + ) : ( + /* Greyed-out ghost trash can — purely decorative, but uses the + same button wrapper so spacing matches deletable rows. */ + + ))} + + )} + {!isEditingThisRow && ( + + )} +
); })} + {isSourceText && + isSettingsMode && + (() => { + const maxCellNumber = + getMaxCellNumberForMilestone( + subsections + ); + const canAddBreak = maxCellNumber >= 2; + const isFormOpen = + addBreakMilestoneIdx === milestoneIdx; + const isMilestoneFormOpen = + addMilestoneMilestoneIdx === + milestoneIdx; + const canAddMilestone = + enableMilestonePlacementEditing && + canAddBreak; + const hasCustomBreaks = subsections.some( + (s) => s.source === "custom" + ); + if ( + !canAddBreak && + !canAddMilestone && + !hasCustomBreaks + ) { + return null; + } + return ( +
+ {isFormOpen ? ( +
+ handleSubmitAddBreak( + e, + milestoneIdx, + maxCellNumber + ) + } + className="flex flex-wrap items-center gap-2" + > + + { + setAddBreakCellNumber( + e.target.value + ); + if (addBreakError) + setAddBreakError( + "" + ); + }} + onKeyDown={(e) => { + if ( + e.key === + "Escape" + ) { + e.preventDefault(); + handleCancelAddBreak(); + } + }} + aria-label="Cell number for new break" + aria-describedby={ + addBreakError + ? `add-break-error-${milestoneIdx}` + : undefined + } + aria-invalid={ + !!addBreakError + } + placeholder="322" + className="w-20 text-xs px-2 py-1 rounded border border-[var(--vscode-input-border)] bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--vscode-focusBorder)]" + /> + + + {addBreakError && ( + + {addBreakError} + + )} +
+ ) : ( + canAddBreak && ( + + ) + )} + {/* Milestone-placement editing form / button. + Only renders when the workspace setting is on and + the parent has enough cells to split. Mutually + exclusive with the subdivision form so only one + is open at a time per milestone. */} + {isMilestoneFormOpen ? ( +
+ handleSubmitAddMilestone( + e, + milestoneIdx, + maxCellNumber + ) + } + className="flex flex-wrap items-center gap-2" + > + + { + setAddMilestoneCellNumber( + e.target.value + ); + if ( + addMilestoneError + ) + setAddMilestoneError( + "" + ); + }} + onKeyDown={(e) => { + if ( + e.key === + "Escape" + ) { + e.preventDefault(); + handleCancelAddMilestone(); + } + }} + aria-label="Cell number for new milestone" + aria-describedby={ + addMilestoneError + ? `add-milestone-error-${milestoneIdx}` + : undefined + } + aria-invalid={ + !!addMilestoneError + } + placeholder="322" + className="w-20 text-xs px-2 py-1 rounded border border-[var(--vscode-input-border)] bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--vscode-focusBorder)]" + /> + + + {addMilestoneError && ( + + {addMilestoneError} + + )} +
+ ) : ( + canAddMilestone && + !isFormOpen && ( + + ) + )} + {hasCustomBreaks && !isFormOpen && ( + + )} +
+ ); + })()}
diff --git a/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts b/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts index 08c81e932..3999fac51 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts @@ -114,7 +114,8 @@ interface UseVSCodeMessageHandlerProps { currentMilestoneIndex: number, currentSubsectionIndex: number, isSourceText: boolean, - sourceCellMap: { [k: string]: { content: string; versions: string[]; }; } + sourceCellMap: { [k: string]: { content: string; versions: string[]; }; }, + force?: boolean ) => void; handleCellPage?: ( milestoneIndex: number, @@ -350,7 +351,8 @@ export const useVSCodeMessageHandler = ({ message.currentMilestoneIndex, message.currentSubsectionIndex, message.isSourceText, - message.sourceCellMap + message.sourceCellMap, + (message as { force?: boolean; }).force === true ); } try { diff --git a/webviews/codex-webviews/src/CodexCellEditor/utils/subdivisionUtils.test.ts b/webviews/codex-webviews/src/CodexCellEditor/utils/subdivisionUtils.test.ts new file mode 100644 index 000000000..17693b398 --- /dev/null +++ b/webviews/codex-webviews/src/CodexCellEditor/utils/subdivisionUtils.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import type { MilestoneInfo, SubdivisionInfo } from "../../../../../types"; +import { buildSubsectionsForMilestone } from "./subdivisionUtils"; + +const makeMilestone = ( + overrides: Partial = {}, + subdivisions?: SubdivisionInfo[] +): MilestoneInfo => ({ + value: "Luke 1", + cellIndex: 0, + cellCount: 10, + firstCellId: "v1", + subdivisions, + ...overrides, +}); + +describe("buildSubsectionsForMilestone", () => { + it("returns [] when milestone is undefined", () => { + const subs = buildSubsectionsForMilestone(0, undefined, 50); + expect(subs).toEqual([]); + }); + + it("returns a single zero-range subsection for empty milestones", () => { + const subs = buildSubsectionsForMilestone(0, makeMilestone({ cellCount: 0 }), 50); + expect(subs).toHaveLength(1); + expect(subs[0].label).toBe("0"); + expect(subs[0].startIndex).toBe(0); + expect(subs[0].endIndex).toBe(0); + }); + + it("prefers resolver subdivisions over arithmetic fallback", () => { + const subs = buildSubsectionsForMilestone( + 0, + makeMilestone({ cellCount: 10 }, [ + { + index: 0, + startRootIndex: 0, + endRootIndex: 5, + key: "__start__", + startCellId: "v1", + name: "Beginning", + source: "custom", + }, + { + index: 1, + startRootIndex: 5, + endRootIndex: 10, + key: "v6", + startCellId: "v6", + source: "custom", + }, + ]), + 50 + ); + expect(subs).toHaveLength(2); + expect(subs[0].label).toBe("1-5"); + expect(subs[0].name).toBe("Beginning"); + expect(subs[0].startCellId).toBe("v1"); + expect(subs[0].source).toBe("custom"); + expect(subs[1].label).toBe("6-10"); + expect(subs[1].key).toBe("v6"); + expect(subs[1].name).toBeUndefined(); + }); + + it("falls back to arithmetic pagination when no subdivisions are provided", () => { + const subs = buildSubsectionsForMilestone( + 0, + makeMilestone({ cellCount: 125 }, undefined), + 50 + ); + expect(subs).toHaveLength(3); + expect(subs.map((s) => s.label)).toEqual(["1-50", "51-100", "101-125"]); + expect(subs.map((s) => [s.startIndex, s.endIndex])).toEqual([ + [0, 50], + [50, 100], + [100, 125], + ]); + }); + + it("arithmetic label matches resolver output for the no-custom-breaks case", () => { + // When the resolver produces auto-only subdivisions, the labels must + // match what the legacy arithmetic path produced. Guarantees no UI + // regression for notebooks without custom breaks. + const arithmetic = buildSubsectionsForMilestone(0, makeMilestone({ cellCount: 125 }), 50); + const resolverEquivalent = buildSubsectionsForMilestone( + 0, + makeMilestone({ cellCount: 125 }, [ + { index: 0, startRootIndex: 0, endRootIndex: 50, key: "__start__", startCellId: "v1", source: "auto" }, + { index: 1, startRootIndex: 50, endRootIndex: 100, key: "v51", startCellId: "v51", source: "auto" }, + { index: 2, startRootIndex: 100, endRootIndex: 125, key: "v101", startCellId: "v101", source: "auto" }, + ]), + 50 + ); + expect(resolverEquivalent.map((s) => s.label)).toEqual(arithmetic.map((s) => s.label)); + expect(resolverEquivalent.map((s) => [s.startIndex, s.endIndex])).toEqual( + arithmetic.map((s) => [s.startIndex, s.endIndex]) + ); + }); + + it("assigns stable IDs based on milestone index", () => { + const subs = buildSubsectionsForMilestone(2, makeMilestone({ cellCount: 100 }), 50); + expect(subs.map((s) => s.id)).toEqual(["milestone-2-page-0", "milestone-2-page-1"]); + }); + + it("handles cellsPerPage=0 gracefully by clamping to 1", () => { + const subs = buildSubsectionsForMilestone(0, makeMilestone({ cellCount: 3 }), 0); + expect(subs).toHaveLength(3); + }); +}); diff --git a/webviews/codex-webviews/src/CodexCellEditor/utils/subdivisionUtils.ts b/webviews/codex-webviews/src/CodexCellEditor/utils/subdivisionUtils.ts new file mode 100644 index 000000000..55e565688 --- /dev/null +++ b/webviews/codex-webviews/src/CodexCellEditor/utils/subdivisionUtils.ts @@ -0,0 +1,72 @@ +import type { MilestoneInfo } from "../../../../../types"; +import type { Subsection } from "../../lib/types"; + +/** + * Builds the UI-facing `Subsection` list for a milestone. Prefers + * provider-computed subdivisions (custom user breaks and/or the arithmetic + * fallback produced by the resolver) and falls back to a local arithmetic + * calculation only when those are absent — typically during the narrow window + * between loading the webview and receiving the first `milestoneIndex` update. + * + * Returns: + * - exactly one zero-range subsection for empty milestones, so the UI never + * renders a nonsensical `"1-0"` label; + * - subsections whose `label` is always a numeric `"-"` range, + * regardless of whether a `name` is present. Callers decide whether to + * display `name` in place of `label`. + */ +export function buildSubsectionsForMilestone( + milestoneIdx: number, + milestone: MilestoneInfo | undefined, + cellsPerPage: number +): Subsection[] { + if (!milestone) return []; + + const { cellCount } = milestone; + + if (cellCount === 0) { + return [ + { + id: `milestone-${milestoneIdx}-page-0`, + label: "0", + startIndex: 0, + endIndex: 0, + }, + ]; + } + + // Resolver-provided subdivisions already encode both custom and arithmetic + // layouts; trust them when available. + if (milestone.subdivisions && milestone.subdivisions.length > 0) { + return milestone.subdivisions.map((sub, i) => { + const startCellNumber = sub.startRootIndex + 1; + const endCellNumber = sub.endRootIndex; + return { + id: `milestone-${milestoneIdx}-page-${i}`, + label: `${startCellNumber}-${endCellNumber}`, + startIndex: sub.startRootIndex, + endIndex: sub.endRootIndex, + name: sub.name, + key: sub.key, + startCellId: sub.startCellId, + source: sub.source, + }; + }); + } + + // Legacy fallback for stale milestoneIndex payloads missing `subdivisions`. + const pageSize = Math.max(1, cellsPerPage); + const totalPages = Math.ceil(cellCount / pageSize) || 1; + const subsections: Subsection[] = []; + for (let i = 0; i < totalPages; i++) { + const startCellNumber = i * pageSize + 1; + const endCellNumber = Math.min((i + 1) * pageSize, cellCount); + subsections.push({ + id: `milestone-${milestoneIdx}-page-${i}`, + label: `${startCellNumber}-${endCellNumber}`, + startIndex: i * pageSize, + endIndex: endCellNumber, + }); + } + return subsections; +} diff --git a/webviews/codex-webviews/src/InterfaceSettings/index.tsx b/webviews/codex-webviews/src/InterfaceSettings/index.tsx index 8c08f6a86..1f813e08c 100644 --- a/webviews/codex-webviews/src/InterfaceSettings/index.tsx +++ b/webviews/codex-webviews/src/InterfaceSettings/index.tsx @@ -49,6 +49,26 @@ function InterfaceSettingsApp() { // Search Settings state const [highlightSearchResults, setHighlightSearchResults] = useState(true); + // Pagination / Subdivision Settings state. `cellsPerPageInput` is a string + // so the field accepts intermediate/invalid values during typing; we parse + // and clamp on blur/Enter before posting. + const [cellsPerPage, setCellsPerPage] = useState(50); + const [cellsPerPageInput, setCellsPerPageInput] = useState("50"); + const [useSubdivisionNumberLabels, setUseSubdivisionNumberLabels] = useState(false); + // `maxSubdivisionLength = 0` means "off" — the resolver falls back to + // using cellsPerPage as the threshold. Storage stays a single number so + // the package.json schema remains simple, but the UI exposes a toggle + + // input pair to make the "off" state feel intentional. + const [maxSubdivisionLength, setMaxSubdivisionLength] = useState(0); + const [maxSubdivisionLengthInput, setMaxSubdivisionLengthInput] = useState("0"); + const maxSubdivisionLengthEnabled = maxSubdivisionLength > 0; + + // Milestone-placement editing is gated behind this opt-in because it + // restructures the document (insert/remove/promote/demote of milestone + // cells) rather than just relabelling. Off by default so existing users + // don't see new structural buttons in their settings menu unannounced. + const [enableMilestonePlacementEditing, setEnableMilestonePlacementEditing] = useState(false); + useEffect(() => { const handler = (event: MessageEvent) => { const message = event.data; @@ -56,6 +76,23 @@ function InterfaceSettingsApp() { if (typeof message.data?.highlightSearchResults === "boolean") { setHighlightSearchResults(message.data.highlightSearchResults); } + if (typeof message.data?.cellsPerPage === "number") { + setCellsPerPage(message.data.cellsPerPage); + setCellsPerPageInput(String(message.data.cellsPerPage)); + } + if (typeof message.data?.useSubdivisionNumberLabels === "boolean") { + setUseSubdivisionNumberLabels(message.data.useSubdivisionNumberLabels); + } + if (typeof message.data?.maxSubdivisionLength === "number") { + const v = Math.max(0, Math.floor(message.data.maxSubdivisionLength)); + setMaxSubdivisionLength(v); + setMaxSubdivisionLengthInput(v > 0 ? String(v) : ""); + } + if (typeof message.data?.enableMilestonePlacementEditing === "boolean") { + setEnableMilestonePlacementEditing( + message.data.enableMilestonePlacementEditing + ); + } } }; window.addEventListener("message", handler); @@ -81,6 +118,91 @@ function InterfaceSettingsApp() { vscode.postMessage({ command: "updateHighlightSearchResults", value: checked }); }; + const CELLS_PER_PAGE_MIN = 5; + const CELLS_PER_PAGE_MAX = 200; + + const commitCellsPerPage = () => { + const parsed = parseInt(cellsPerPageInput, 10); + if (!Number.isFinite(parsed)) { + setCellsPerPageInput(String(cellsPerPage)); + return; + } + const clamped = Math.max(CELLS_PER_PAGE_MIN, Math.min(CELLS_PER_PAGE_MAX, parsed)); + if (clamped === cellsPerPage) { + setCellsPerPageInput(String(cellsPerPage)); + return; + } + setCellsPerPage(clamped); + setCellsPerPageInput(String(clamped)); + vscode.postMessage({ command: "updateCellsPerPage", value: clamped }); + }; + + const handleToggleUseSubdivisionNumberLabels = (checked: boolean) => { + setUseSubdivisionNumberLabels(checked); + vscode.postMessage({ + command: "updateUseSubdivisionNumberLabels", + value: checked, + }); + }; + + const MAX_SUBDIVISION_LENGTH_MIN = 1; + const MAX_SUBDIVISION_LENGTH_MAX = 5000; + + const sendMaxSubdivisionLength = (value: number) => { + setMaxSubdivisionLength(value); + setMaxSubdivisionLengthInput(value > 0 ? String(value) : ""); + vscode.postMessage({ + command: "updateMaxSubdivisionLength", + value, + }); + }; + + const commitMaxSubdivisionLength = () => { + const parsed = parseInt(maxSubdivisionLengthInput, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + // Empty / non-numeric / non-positive input → revert to the last + // committed value's display rather than silently turning the + // setting off. + setMaxSubdivisionLengthInput( + maxSubdivisionLength > 0 ? String(maxSubdivisionLength) : "" + ); + return; + } + const clamped = Math.max( + MAX_SUBDIVISION_LENGTH_MIN, + Math.min(MAX_SUBDIVISION_LENGTH_MAX, parsed) + ); + if (clamped === maxSubdivisionLength) { + setMaxSubdivisionLengthInput(String(clamped)); + return; + } + sendMaxSubdivisionLength(clamped); + }; + + const handleToggleMaxSubdivisionLength = (checked: boolean) => { + if (checked) { + // Default the input to roughly twice the current page size, which + // is the most common "let small uneven pages stay intact" setup. + const seed = Math.max( + cellsPerPage * 2, + MAX_SUBDIVISION_LENGTH_MIN + ); + sendMaxSubdivisionLength( + Math.min(seed, MAX_SUBDIVISION_LENGTH_MAX) + ); + } else { + sendMaxSubdivisionLength(0); + } + }; + + const handleToggleEnableMilestonePlacementEditing = (checked: boolean) => { + setEnableMilestonePlacementEditing(checked); + vscode.postMessage({ + command: "updateEnableMilestonePlacementEditing", + value: checked, + }); + }; + return (
@@ -262,6 +384,126 @@ function InterfaceSettingsApp() {
+ {/* Pagination & Subdivisions Section */} +
+
+ + Pagination & Subdivisions +
+ +
+ {/* Cells per page */} +
+
+
Cells per page
+
+ Default page size for milestones without custom breaks + . +
+
+ + setCellsPerPageInput(e.target.value.replace(/[^0-9]/g, "")) + } + onBlur={commitCellsPerPage} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } + }} + className="w-24 bg-transparent border border-border rounded px-2 py-1 text-sm text-right" + aria-label="Cells per page" + /> +
+ + {/* Always show number ranges */} + {false && ( +
+
+
+ Always show subdivision number ranges +
+
+ Display the numeric cell range (e.g. "6-15") even when a + subdivision has a name. Names are shown otherwise. +
+
+ +
+ )} + + {/* Maximum subdivision length */} +
+
+
+ Maximum subdivision length +
+
+ Pagination allows ranges between user added subdivisions up to this length +
+
+
+ {maxSubdivisionLengthEnabled && ( + + setMaxSubdivisionLengthInput( + e.target.value.replace(/[^0-9]/g, "") + ) + } + onBlur={commitMaxSubdivisionLength} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + (e.target as HTMLInputElement).blur(); + } + }} + className="w-24 bg-transparent border border-border rounded px-2 py-1 text-sm text-right" + aria-label="Maximum subdivision length" + placeholder={String(cellsPerPage * 2)} + /> + )} + +
+
+ + {/* Milestone placement editing */} +
+
+
+ Edit milestone placement +
+
+ Show controls in the milestone accordion to add, remove, + promote, or demote milestones on source files. Edits mirror + to the paired target. +
+
+ +
+
+
+ {/* Search Settings Section */}
diff --git a/webviews/codex-webviews/src/lib/types.ts b/webviews/codex-webviews/src/lib/types.ts index 1fb1ae5d4..bfaef2c0c 100644 --- a/webviews/codex-webviews/src/lib/types.ts +++ b/webviews/codex-webviews/src/lib/types.ts @@ -62,6 +62,26 @@ export interface Subsection { label: string; startIndex: number; endIndex: number; + /** + * User-assigned name for this subdivision, when present. The navigation + * header and milestone accordion prefer `name` over `label` for display; + * callers that always want a numeric range should continue to read + * `label`. + */ + name?: string; + /** + * Stable key for this subdivision (typically `startCellId`, or a reserved + * key for the implicit first subdivision). Used when persisting + * name/placement edits back to the provider. + */ + key?: string; + /** + * ID of the root content cell that anchors this subdivision's start. + * Undefined when the subdivision wraps an empty milestone. + */ + startCellId?: string; + /** Whether the subdivision boundary was user-authored or auto-calculated. */ + source?: "auto" | "custom"; } export type FileStatus = "dirty" | "syncing" | "synced" | "none";