diff --git a/package.json b/package.json index fb0dbb3cb..e73852015 100644 --- a/package.json +++ b/package.json @@ -941,11 +941,26 @@ "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.useOnlyValidatedExamples": { "title": "Use Only Validated Examples", diff --git a/src/interfaceSettings/interfaceSettings.ts b/src/interfaceSettings/interfaceSettings.ts index 04faa39c1..9c32e8720 100644 --- a/src/interfaceSettings/interfaceSettings.ts +++ b/src/interfaceSettings/interfaceSettings.ts @@ -62,11 +62,20 @@ 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); panel.webview.postMessage({ command: "init", data: { highlightSearchResults, + cellsPerPage, + useSubdivisionNumberLabels, + maxSubdivisionLength, }, }); }; @@ -99,6 +108,66 @@ 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; + } + } + }); + + // 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") + ) { + sendInit(); } }); + + panel.onDidDispose(() => { + configListener.dispose(); + }); } diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 5a5c84ae1..9c85874aa 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -21,6 +21,9 @@ 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 } from "./utils/subdivisionUtils"; +import type { MilestoneSubdivisionPlacement } from "../../../types"; // Enable debug logging if needed const DEBUG_MODE = false; @@ -134,7 +137,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 +150,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 +166,10 @@ export async function sendMilestoneRefreshToWebview( const username = userInfo?.username || "anonymous"; const rev = provider.getDocumentRevision(docUri); + const useSubdivisionNumberLabels = config.get( + "useSubdivisionNumberLabels", + false + ); safePostMessageToPanel(webviewPanel, { type: "providerSendsInitialContentPaginated", rev, @@ -170,6 +182,7 @@ export async function sendMilestoneRefreshToWebview( username: username, validationCount: validationCount, validationCountAudio: validationCountAudio, + useSubdivisionNumberLabels, }); safePostMessageToPanel(webviewPanel, { @@ -184,6 +197,223 @@ export async function sendMilestoneRefreshToWebview( } } +/** + * 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; + + try { + await document.refreshAuthor(); + document.updateCellData(sourceCellId, { subdivisions: sanitized }); + await provider.saveCustomDocument(document, cancellationToken); + } 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, + }); + await provider.saveCustomDocument(targetDocument, cancellationToken); + 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 + ); + } + } + } +} + /** * Helper function to get the audio file path for a cell * Checks metadata attachments first, then falls back to filesystem lookup @@ -315,7 +545,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 }) => { @@ -1271,6 +1502,359 @@ const messageHandlers: Record Promise { + 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; + } + + 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; + } + + // 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 document.refreshAuthor(); + document.updateCellData(milestoneCell.metadata.id, { + subdivisionNames: nextNames, + }); + } catch (error) { + console.error( + "[updateMilestoneSubdivisionName] Failed to stage name update before promotion:", + error + ); + vscode.window.showErrorMessage( + `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; + } + + try { + await document.refreshAuthor(); + document.updateCellData(milestoneCell.metadata.id, { subdivisionNames: nextNames }); + await provider.saveCustomDocument(document, cancellationToken); + debug( + `[updateMilestoneSubdivisionName] Updated name for milestone ${milestoneIndex}, key ${subdivisionKey}` + ); + } catch (error) { + console.error("[updateMilestoneSubdivisionName] Failed:", error); + vscode.window.showErrorMessage( + `Failed to rename subdivision: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + + // 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, + }); + await provider.saveCustomDocument(targetDocument, cancellationToken); + 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 + ); + } + } + } + }, + updateNotebookMetadata: async ({ event, document, webviewPanel, provider }) => { const typedEvent = event as Extract; debug("updateNotebookMetadata message received", { event }); @@ -3329,9 +3913,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); @@ -3811,3 +4405,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 af5d5ed0c..ff1de82c8 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,29 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider("maxSubdivisionLength", 0); + return typeof raw === "number" && raw > 0 ? Math.floor(raw) : 0; + } + private bumpDocumentRevision(documentUri: string): number { const next = (this.documentRevisions.get(documentUri) ?? 0) + 1; this.documentRevisions.set(documentUri, next); @@ -295,14 +318,63 @@ 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, + }); }); } }); @@ -760,7 +832,10 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { @@ -828,7 +903,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 +2617,10 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { @@ -2553,7 +2689,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 +2906,14 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider= 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; @@ -1424,13 +1530,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, }; @@ -1438,11 +1555,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, @@ -1452,6 +1584,7 @@ export class CodexCellDocument implements vscode.CustomDocument { // Cache the result this._cachedMilestoneIndex = result; this._cachedMilestoneIndexCellsPerPage = cellsPerPage; + this._cachedMilestoneIndexMaxSubdivisionLength = maxSubdivisionLength; this._cachedMilestoneIndexCellCount = currentCellCount; return result; @@ -1556,7 +1689,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 @@ -1574,7 +1707,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++) { @@ -1617,10 +1750,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 }; } @@ -1753,7 +1893,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) { @@ -1804,19 +1945,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]) @@ -1949,10 +2097,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) { @@ -2008,17 +2157,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.) @@ -2151,34 +2308,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) { @@ -2920,7 +3062,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..b596c1f76 --- /dev/null +++ b/src/providers/codexCellEditorProvider/utils/subdivisionUtils.ts @@ -0,0 +1,282 @@ +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; +} diff --git a/src/test/suite/milestoneSubdivisions.test.ts b/src/test/suite/milestoneSubdivisions.test.ts new file mode 100644 index 000000000..f27c8c974 --- /dev/null +++ b/src/test/suite/milestoneSubdivisions.test.ts @@ -0,0 +1,1023 @@ +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, + resolveSubdivisions, +} 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 + 1}`); + + 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, "c51"); + }); + + 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, 5], + ); + assert.deepStrictEqual( + [result[1].startRootIndex, result[1].endRootIndex], + [5, 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, 2, 5, 7], + ); + }); + + 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, 2, 2, 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 c1 names the implicit first subdivision rather than creating a new one", () => { + const rootIds = ids(4); + const result = resolveSubdivisions({ + rootContentCellIds: rootIds, + placements: [{ startCellId: "c1", 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: "c4" }, { startCellId: "c8" }], + 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); + }); + }); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 5271e2902..dce6a5782 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -604,6 +604,47 @@ 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; + }; + } | { command: "refreshWebviewAfterMilestoneEdits"; content?: Record; @@ -712,8 +753,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; @@ -908,6 +1009,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[]; } /** @@ -1881,6 +1991,12 @@ 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; } | { type: "providerSendsCellPage"; @@ -2169,6 +2285,15 @@ 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: "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..9ca2de5d8 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx @@ -90,6 +90,11 @@ 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; } export function ChapterNavigationHeader({ @@ -149,6 +154,7 @@ export function ChapterNavigationHeader({ subsectionProgress, allSubsectionProgress, requestSubsectionProgress, + useSubdivisionNumberLabels = false, }: // Removed onToggleCorrectionEditor since it will be a VS Code command now ChapterNavigationHeaderProps) { const [showConfirm, setShowConfirm] = useState(false); @@ -1078,6 +1084,7 @@ ChapterNavigationHeaderProps) { calculateSubsectionProgress={calculateSubsectionProgress} requestSubsectionProgress={requestSubsectionProgress} vscode={vscode} + useSubdivisionNumberLabels={useSubdivisionNumberLabels} /> ); diff --git a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx index d14344976..b2976bc9f 100755 --- a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx @@ -33,6 +33,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"; @@ -241,6 +242,11 @@ 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); + // Track cells currently transcribing audio (to show the same loading effect as translations) const [transcribingCells, setTranscribingCells] = useState>(new Set()); @@ -1847,38 +1853,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] ); @@ -2362,6 +2338,17 @@ 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.type === "updateSubdivisionLabelPreference") { + setUseSubdivisionNumberLabels( + Boolean(event.data.useSubdivisionNumberLabels) + ); } }, [] @@ -3245,6 +3232,7 @@ const CodexCellEditor: React.FC = () => { subsectionProgress={subsectionProgress[currentMilestoneIndex]} allSubsectionProgress={subsectionProgress} requestSubsectionProgress={requestSubsectionProgressForMilestone} + useSubdivisionNumberLabels={useSubdivisionNumberLabels} /> diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx index 23eb68b11..97b9f3000 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx @@ -15,6 +15,7 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ appearance, title, "aria-label": ariaLabel, + "aria-pressed": ariaPressed, }: any) => ( @@ -62,6 +64,9 @@ vi.mock("lucide-react", () => ({ Languages: () =>
Languages
, Check: () =>
Check
, RotateCcw: () =>
RotateCcw
, + X: () =>
X
, + Undo2: () =>
Undo2
, + Plus: () =>
Plus
, })); vi.mock("../../components/ui/icons/MicrophoneIcon", () => ({ @@ -174,6 +179,12 @@ describe("MilestoneAccordion - Milestone Editing", () => { calculateSubsectionProgress: mockCalculateSubsectionProgress, requestSubsectionProgress: mockRequestSubsectionProgress, vscode: mockVscode, + // Most editing-flow tests assert directly against the title pencil, + // per-subsection 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(); @@ -758,6 +769,741 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); + describe("Subsection 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 subsections 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 Subsection"); + // 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 subsection 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 Subsection"); + 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 Subsection Name"); + 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 Subsection"); + 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 Subsection Name"); + 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 Subsection"); + await act(async () => { + fireEvent.click(renameBtn); + }); + + const saveBtn = screen.getByLabelText("Save Subsection Name"); + await act(async () => { + fireEvent.click(saveBtn); + }); + + const renameCalls = mockVscode.postMessage.mock.calls.filter( + (call: any[]) => call[0]?.command === "updateMilestoneSubdivisionName" + ); + expect(renameCalls).toHaveLength(0); + }); + + 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 Subsection"); + 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 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("Subsection 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 subsections 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, the title pencil is gone, and per-subsection + // pencils + add-break / reset footers stay hidden. + expect(screen.queryByLabelText("Edit Milestone")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Rename Subsection")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Add Subdivision Break")).not.toBeInTheDocument(); + const gearButton = screen.getByLabelText("Toggle Milestone Settings"); + expect(gearButton).toHaveAttribute("aria-pressed", "false"); + + await act(async () => { + fireEvent.click(gearButton); + }); + + expect(screen.getByLabelText("Edit Milestone")).toBeInTheDocument(); + expect(screen.getByLabelText("Toggle Milestone Settings")).toHaveAttribute( + "aria-pressed", + "true" + ); + }); + + it("reveals per-subsection rename pencils when settings mode is open", async () => { + // Subsections 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 Subsection")).not.toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByLabelText("Toggle Milestone Settings")); + }); + + const renamePencils = screen.getAllByLabelText("Rename Subsection"); + 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(screen.getByLabelText("Edit Milestone")).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( + + ); + }); + + expect(screen.queryByLabelText("Edit Milestone")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Toggle Milestone Settings")).toHaveAttribute( + "aria-pressed", + "false" + ); + }); + }); + describe("Edit Mode - 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(); diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx index 5bd7c604f..ad567b4fb 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -10,7 +10,7 @@ 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 } 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 +46,21 @@ 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; } export function MilestoneAccordion({ @@ -63,6 +78,8 @@ export function MilestoneAccordion({ calculateSubsectionProgress, requestSubsectionProgress, vscode, + useSubdivisionNumberLabels = false, + initialSettingsMode = false, }: MilestoneAccordionProps) { // Layout constants const DROPDOWN_MAX_HEIGHT_VIEWPORT_PERCENT = 60; // 60vh @@ -89,9 +106,198 @@ export function MilestoneAccordion({ const [editedMilestoneValue, setEditedMilestoneValue] = useState(""); const [originalMilestoneValue, setOriginalMilestoneValue] = useState(""); const inputRef = useRef(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); + + useEffect(() => { + return () => { + if (resetConfirmTimeoutRef.current !== null) { + window.clearTimeout(resetConfirmTimeoutRef.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]); + + /** + * 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(); + }; + // Calculate position and dimensions const calculatePositionAndDimensions = () => { if (isOpen && anchorRef.current) { @@ -214,7 +420,14 @@ export function MilestoneAccordion({ useEffect(() => { if (!isOpen) { setIsEditingMilestone(false); + // 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,19 +713,23 @@ 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; + + // Ensure the edited milestone is what the header will render. + setExpandedMilestone(milestoneIdx.toString()); + + const displayValue = localMilestoneValues[milestoneIdx] || milestone.value; + setOriginalMilestoneValue(displayValue); + setEditedMilestoneValue(displayValue); + setIsEditingMilestone(true); + + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); }; const handleSaveMilestone = (e: React.MouseEvent) => { @@ -577,6 +794,67 @@ export function MilestoneAccordion({ } }; + 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); + }; + + 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 - if editing, switch to editing the new milestone const handleMilestoneExpansion = (value: string | null) => { // Update expanded milestone first @@ -670,15 +948,31 @@ export function MilestoneAccordion({ ) : ( - - - + <> + { + e.stopPropagation(); + setIsSettingsMode((prev) => !prev); + }} + aria-pressed={isSettingsMode} + > + + + )}
+ {isSettingsMode && ( + <> + + beginEditMilestone( + e, + milestoneIdx + ) + } + > + + + + + )}
+ 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 && + (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 hasCustomBreaks = subsections.some( + (s) => s.source === "custom" + ); + if (!canAddBreak && !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 && ( + + ) + )} + {hasCustomBreaks && !isFormOpen && ( + + )} +
+ ); + })()}
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..fe7dfbb97 100644 --- a/webviews/codex-webviews/src/InterfaceSettings/index.tsx +++ b/webviews/codex-webviews/src/InterfaceSettings/index.tsx @@ -49,6 +49,20 @@ 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; + useEffect(() => { const handler = (event: MessageEvent) => { const message = event.data; @@ -56,6 +70,18 @@ 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) : ""); + } } }; window.addEventListener("message", handler); @@ -81,6 +107,83 @@ 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); + } + }; + return (
@@ -262,6 +365,108 @@ 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)} + /> + )} + +
+
+
+
+ {/* 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";