From c44c04c09020bfe02e06ed908cc888f26e981a07 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Wed, 22 Apr 2026 16:39:47 -0500 Subject: [PATCH 01/34] Add subdivision-aware milestone pagination resolver Introduce a dedicated subdivision model that lets milestones describe custom rendering breaks, while preserving today's arithmetic 50-cell chunking as the default fallback. Pagination, subsection lookup, and subsection progress now all go through a single resolver, so future writes to `metadata.data.subdivisions` immediately change what the editor renders without touching any other code path. - Types: add `MilestoneSubdivisionPlacement`, `SubdivisionInfo`, and `metadata.data.subdivisions` / `metadata.data.subdivisionNames` on milestone cells; extend `MilestoneInfo` with resolved `subdivisions` and add `updateMilestoneSubdivisions` / `updateMilestoneSubdivisionName` editor messages for upcoming writers. - Resolver (`utils/subdivisionUtils.ts`): pure function that returns ordered, non-overlapping subdivisions for a milestone. Handles arithmetic fallback, stable ordering, anchor pruning on deleted cells, duplicate collapse, and target-side name overrides via `FIRST_SUBDIVISION_KEY`. - CodexCellDocument: `buildMilestoneIndex`, `getCellsForMilestone`, `getSubsectionCountForMilestone`, `findMilestoneAndSubsectionForCell`, and `calculateSubsectionProgress` now share the resolver. Behavior is byte-identical for documents without custom subdivisions. - Tests: add unit + integration coverage for the resolver and the document APIs (custom breaks, slicing, stale anchor pruning, legacy 50-cell behavior). Made-with: Cursor --- .../codexCellEditorProvider/codexDocument.ts | 182 ++++++--- .../utils/subdivisionUtils.ts | 202 ++++++++++ src/test/suite/milestoneSubdivisions.test.ts | 356 ++++++++++++++++++ types/index.d.ts | 82 ++++ 4 files changed, 778 insertions(+), 44 deletions(-) create mode 100644 src/providers/codexCellEditorProvider/utils/subdivisionUtils.ts create mode 100644 src/test/suite/milestoneSubdivisions.test.ts diff --git a/src/providers/codexCellEditorProvider/codexDocument.ts b/src/providers/codexCellEditorProvider/codexDocument.ts index 7ffbd3e87..03e66f5a2 100644 --- a/src/providers/codexCellEditorProvider/codexDocument.ts +++ b/src/providers/codexCellEditorProvider/codexDocument.ts @@ -15,6 +15,8 @@ import { MilestoneIndex, MilestoneInfo, CustomCellMetaData, + MilestoneSubdivisionPlacement, + SubdivisionInfo, } from "../../../types"; import { EditMapUtils, deduplicateFileMetadataEdits } from "../../utils/editMapUtils"; import { CodexCellTypes, EditType } from "../../../types/enums"; @@ -25,6 +27,7 @@ import { debounce } from "lodash"; import { getSQLiteIndexManager, isDBShuttingDown } from "../../activationHelpers/contextAware/contentIndexes/indexes/sqliteIndexManager"; import { getCellValueData, cellHasAudioUsingAttachments, computeValidationStats, computeProgressPercents, shouldExcludeCellFromProgress, shouldExcludeQuillCellFromProgress, countActiveValidations, hasTextContent } from "../../../sharedUtils"; import { extractParentCellIdFromParatext, convertCellToQuillContent } from "./utils/cellUtils"; +import { FIRST_SUBDIVISION_KEY, findSubdivisionIndexForRoot, resolveSubdivisions } from "./utils/subdivisionUtils"; import { formatJsonForNotebookFile, normalizeNotebookFileText } from "../../utils/notebookFileFormattingUtils"; import { serializeNotebookWithCellCache } from "./utils/cachedNotebookSerializer"; import { atomicWriteUriText, readExistingFileOrThrow } from "../../utils/notebookSafeSaveUtils"; @@ -1442,6 +1445,69 @@ export class CodexCellDocument implements vscode.CustomDocument { return false; } + /** + * 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 + ): 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; }; } + | undefined; + return resolveSubdivisions({ + rootContentCellIds, + placements: data?.subdivisions, + nameOverrides: data?.subdivisionNames, + cellsPerPage, + }); + } + /** * Builds a milestone index from the document cells. * This index is cached and reused until cells are modified. @@ -1557,13 +1623,23 @@ 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, + }); + const result: MilestoneIndex = { - milestones: [{ - index: 0, - cellIndex: 0, - value: "1", - cellCount: totalContentCells, - }], + milestones: [virtualMilestone], totalCells: totalContentCells, cellsPerPage, }; @@ -1576,6 +1652,19 @@ export class CodexCellDocument implements vscode.CustomDocument { 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 + ); + } + const result: MilestoneIndex = { milestones, totalCells: totalContentCells, @@ -1750,10 +1839,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 }; } @@ -1937,19 +2033,25 @@ 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, + }); + 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]) @@ -2141,17 +2243,24 @@ 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, + }); + 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.) @@ -2285,7 +2394,6 @@ export class CodexCellDocument implements vscode.CustomDocument { * @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); if (milestoneIndex < 0 || milestoneIndex >= milestoneInfo.milestones.length) { @@ -2293,25 +2401,11 @@ export class CodexCellDocument implements vscode.CustomDocument { } 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) { diff --git a/src/providers/codexCellEditorProvider/utils/subdivisionUtils.ts b/src/providers/codexCellEditorProvider/utils/subdivisionUtils.ts new file mode 100644 index 000000000..32b0bfafd --- /dev/null +++ b/src/providers/codexCellEditorProvider/utils/subdivisionUtils.ts @@ -0,0 +1,202 @@ +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[]; + /** + * Target-side display name overrides, keyed by subdivision key (typically + * `startCellId`, or `FIRST_SUBDIVISION_KEY` for the implicit first subdivision). + */ + nameOverrides?: { [key: string]: string; }; + /** + * Arithmetic fallback chunk size, used when there are no custom placements. + * When custom placements exist, this value is ignored. + */ + cellsPerPage: 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, + cellsPerPage, + firstSubdivisionDefaultName, + } = opts; + + const totalRoots = rootContentCellIds.length; + if (totalRoots === 0) { + return []; + } + + const resolveOverride = (key: string): string | undefined => { + if (!nameOverrides) return undefined; + const value = nameOverrides[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; + }; + + // No custom placements → arithmetic chunking (preserves legacy behavior). + if (!placements || placements.length === 0) { + const pageSize = Math.max(1, cellsPerPage); + const pages = Math.max(1, Math.ceil(totalRoots / pageSize)); + const result: SubdivisionInfo[] = []; + for (let i = 0; i < pages; i++) { + const startRootIndex = i * pageSize; + const endRootIndex = Math.min(startRootIndex + pageSize, totalRoots); + const startCellId = rootContentCellIds[startRootIndex]; + const key = i === 0 ? FIRST_SUBDIVISION_KEY : startCellId ?? `auto-${i}`; + result.push({ + index: i, + startRootIndex, + endRootIndex, + key, + startCellId, + name: i === 0 ? resolveOverride(FIRST_SUBDIVISION_KEY) ?? firstSubdivisionDefaultName : resolveOverride(key), + source: "auto", + }); + } + return result; + } + + // 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 subdivision list: implicit first + each resolved break. + const result: SubdivisionInfo[] = []; + const firstEnd = resolved.length > 0 ? resolved[0].rootIndex : totalRoots; + const firstStartCellId = rootContentCellIds[0]; + result.push({ + index: 0, + startRootIndex: 0, + endRootIndex: firstEnd, + key: FIRST_SUBDIVISION_KEY, + startCellId: firstStartCellId, + 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; + result.push({ + index: i + 1, + startRootIndex: anchor.rootIndex, + endRootIndex, + key: anchor.key, + startCellId: anchor.startCellId, + name: resolveOverride(anchor.key) ?? anchor.name, + source: "custom", + }); + } + + return result; +} + +/** + * 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..0324ac797 --- /dev/null +++ b/src/test/suite/milestoneSubdivisions.test.ts @@ -0,0 +1,356 @@ +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 { + 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); + }); + }); + + // --------------------------------------------------------------------------- + // 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("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..109006b31 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -604,6 +604,29 @@ 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; + }; + } | { command: "refreshWebviewAfterMilestoneEdits"; content?: Record; @@ -712,8 +735,58 @@ 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[]; + /** + * Optional target-side localized name overrides for a milestone's subdivisions. + * Keyed by `startCellId` (or "__start__" for the implicit first subdivision). + * Only meaningful on target milestone cells. + */ + subdivisionNames?: { [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 +981,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[]; } /** From 86e2659803517059d238e67b7ada58720257fc90 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Wed, 22 Apr 2026 16:47:09 -0500 Subject: [PATCH 02/34] =?UTF-8?q?Handle=20milestone=20subdivision=20writes?= =?UTF-8?q?=20with=20source=E2=86=92target=20mirroring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up `updateMilestoneSubdivisions` and `updateMilestoneSubdivisionName` editor messages so the UI can now persist custom subdivision breaks and names. Placements are source-authoritative — edits from a `.codex` document are rejected and mirrored onto the paired source's target only after a root-cell-ID sanity check confirms the two milestones line up. Names live on a separate `subdivisionNames` map per document so source and target can be renamed independently. - Message handlers: validate anchors against current root content cells, strip names on mirror (target uses its own override map), recover gracefully when the paired document diverges, and refresh the webview via the existing milestone-refresh path. - Provider helpers: `getPairedNotebookUri` and `getOrOpenDocumentForUri` keep the paired-document plumbing localized to the provider and reuse any already-open `CodexCellDocument`. - Document: expose `getRootContentCellIdsForMilestone` for anchor validation / pairing checks, and invalidate the milestone-index cache whenever `subdivisions` or `subdivisionNames` change so renames and break edits appear on the next render. - Tests: cover anchor-set extraction (including paratext / child / deleted exclusions), cache invalidation on both subdivisions and name overrides, and the full round-trip from placements → arithmetic fallback when placements are cleared. Made-with: Cursor --- .../codexCellEditorMessagehandling.ts | 217 ++++++++++++++++++ .../codexCellEditorProvider.ts | 44 +++- .../codexCellEditorProvider/codexDocument.ts | 27 ++- src/test/suite/milestoneSubdivisions.test.ts | 98 ++++++++ 4 files changed, 384 insertions(+), 2 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 9dbe27043..6fef76330 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -21,6 +21,7 @@ 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"; // Enable debug logging if needed const DEBUG_MODE = false; @@ -1271,6 +1272,222 @@ const messageHandlers: Record Promise { + const typedEvent = event as Extract; + debug("updateMilestoneSubdivisions message received", { event }); + + // Placements are source-authoritative. Reject writes originating from a + // target (.codex) document so the source stays the single source of truth. + if (!isSourceFileFlexible(document.uri)) { + console.warn( + "[updateMilestoneSubdivisions] Rejected write from non-source document:", + document.uri.toString() + ); + vscode.window.showWarningMessage( + "Subdivision breaks can only be edited from the source file." + ); + return; + } + + const { milestoneIndex, subdivisions: incomingPlacements } = typedEvent.content; + + const milestoneIndexObj = document.buildMilestoneIndex(); + const milestone = milestoneIndexObj.milestones[milestoneIndex]; + if (!milestone) { + console.error("[updateMilestoneSubdivisions] Milestone not found at index", milestoneIndex); + vscode.window.showErrorMessage( + `Failed to update subdivisions: 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( + "[updateMilestoneSubdivisions] Cell at index is not a valid milestone cell", + milestone.cellIndex + ); + vscode.window.showErrorMessage("Failed to update subdivisions: 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 callers can surface errors. + const validRootIds = new Set(document.getRootContentCellIdsForMilestone(milestoneIndex)); + const seen = new Set(); + const sanitized: typeof incomingPlacements = []; + for (const placement of incomingPlacements ?? []) { + if (!placement || typeof placement.startCellId !== "string") continue; + if (!validRootIds.has(placement.startCellId)) { + console.warn( + "[updateMilestoneSubdivisions] Dropping unknown startCellId:", + placement.startCellId + ); + continue; + } + if (seen.has(placement.startCellId)) continue; + seen.add(placement.startCellId); + // Intentionally strip names from mirrored placements; names live on + // the separate `subdivisionNames` map so that source and target can + // be named independently (per design spec). + 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); + debug( + `[updateMilestoneSubdivisions] Updated source subdivisions for milestone ${milestoneIndex}`, + { count: sanitized.length } + ); + } catch (error) { + console.error("[updateMilestoneSubdivisions] Failed to update source:", error); + vscode.window.showErrorMessage( + `Failed to update subdivisions: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + + // Mirror placements (without names) to the paired target document. + 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( + "[updateMilestoneSubdivisions] Target has no milestone at index, skipping mirror:", + milestoneIndex + ); + } else { + // Sanity check: positionally-paired milestones should share + // root content cell IDs. If they diverge, skip the mirror to + // avoid attaching source anchors to 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( + "[updateMilestoneSubdivisions] Source/target milestones diverge; skipping mirror.", + { + milestoneIndex, + sourceLength: sourceRootIds.length, + targetLength: targetRootIds.length, + } + ); + } else { + const targetMilestoneCell = targetDocument.getCellByIndex(targetMilestone.cellIndex); + if (targetMilestoneCell?.metadata?.id) { + // Mirror placements with names stripped — target uses + // its own `subdivisionNames` for local display. + const mirroredPlacements = sanitized.map((p) => ({ + startCellId: p.startCellId, + })); + await targetDocument.refreshAuthor(); + targetDocument.updateCellData(targetMilestoneCell.metadata.id, { + subdivisions: mirroredPlacements, + }); + await provider.saveCustomDocument(targetDocument, cancellationToken); + debug( + "[updateMilestoneSubdivisions] Mirrored subdivisions to target:", + { uri: pairedUri.toString(), count: mirroredPlacements.length } + ); + } + } + } + } + } catch (mirrorError) { + // Mirror failures are non-fatal: the source edit has already + // succeeded. Log and continue so the user can retry later. + console.error( + "[updateMilestoneSubdivisions] Failed to mirror to target:", + mirrorError + ); + } + + await sendMilestoneRefreshToWebview(document, webviewPanel, provider); + }, + + 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 existingNames = + (milestoneCell.metadata?.data as { subdivisionNames?: { [k: string]: string; }; } | undefined) + ?.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; + } + + const cancellationToken = new vscode.CancellationTokenSource().token; + 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; + } + + await sendMilestoneRefreshToWebview(document, webviewPanel, provider); + }, + updateNotebookMetadata: async ({ event, document, webviewPanel, provider }) => { const typedEvent = event as Extract; debug("updateNotebookMetadata message received", { event }); diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index a172d83de..cad362426 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; @@ -1686,6 +1686,48 @@ 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 diff --git a/src/providers/codexCellEditorProvider/codexDocument.ts b/src/providers/codexCellEditorProvider/codexDocument.ts index 03e66f5a2..83f98ea9b 100644 --- a/src/providers/codexCellEditorProvider/codexDocument.ts +++ b/src/providers/codexCellEditorProvider/codexDocument.ts @@ -1445,6 +1445,24 @@ export class CodexCellDocument implements vscode.CustomDocument { return false; } + /** + * Returns the ordered list of root content cell IDs belonging to a milestone, + * in document order. Useful for anchor validation (incoming + * `updateMilestoneSubdivisions` writes) and for source↔target alignment + * sanity checks before mirroring placements. + * + * Returns an empty array if the milestone index is out of bounds. + */ + public getRootContentCellIdsForMilestone(milestoneIndex: number): string[] { + const cells = this._documentData.cells || []; + const info = this.buildMilestoneIndex(); + if (milestoneIndex < 0 || milestoneIndex >= info.milestones.length) return []; + const milestone = info.milestones[milestoneIndex]; + const next = info.milestones[milestoneIndex + 1]; + const end = next ? next.cellIndex : cells.length; + return this.getRootContentCellIdsInRange(milestone.cellIndex, end); + } + /** * Returns the ordered list of root content cell IDs within the given index * range. Root content cells are non-milestone, non-paratext, non-deleted cells @@ -3147,7 +3165,14 @@ 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 + // 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; + const shouldInvalidateCache = + isMilestoneCell && (isModifyingDeletedFlag || isModifyingSubdivisions); // Ensure metadata exists if (!this._documentData.cells[indexOfCellToUpdate].metadata) { diff --git a/src/test/suite/milestoneSubdivisions.test.ts b/src/test/suite/milestoneSubdivisions.test.ts index 0324ac797..b5406c087 100644 --- a/src/test/suite/milestoneSubdivisions.test.ts +++ b/src/test/suite/milestoneSubdivisions.test.ts @@ -327,6 +327,104 @@ suite("Milestone Subdivisions Test Suite", () => { ); }); + 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", + ); + }); + 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. From b2c6457b038f210257cf02257d33636c0f44fa77 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Wed, 22 Apr 2026 16:51:16 -0500 Subject: [PATCH 03/34] Thread resolver subdivisions through the editor webview The editor now prefers provider-computed subdivisions when building the UI's `Subsection` list, so custom milestone breaks and their names flow through to navigation the moment the resolver emits them. The prior arithmetic calculation remains as a pure fallback for the brief window before the first `milestoneIndex` lands (or for any stale payloads missing `subdivisions`), which preserves today's behaviour exactly for notebooks without custom breaks. - Extract the subsection-building logic into `CodexCellEditor/utils/subdivisionUtils.ts` so it can be unit-tested independently and reused by future tail-append / renaming paths. - Extend the `Subsection` type with `name`, `key`, `startCellId`, and `source` so downstream UI (accordion, header) can display names, tell custom breaks from auto ones, and echo the stable key back to the provider on renames. - Add unit tests covering empty milestones, subdivision preference, arithmetic fallback parity, ID stability, and edge-case page sizes. Made-with: Cursor --- .../src/CodexCellEditor/CodexCellEditor.tsx | 33 +----- .../utils/subdivisionUtils.test.ts | 109 ++++++++++++++++++ .../CodexCellEditor/utils/subdivisionUtils.ts | 72 ++++++++++++ webviews/codex-webviews/src/lib/types.ts | 20 ++++ 4 files changed, 203 insertions(+), 31 deletions(-) create mode 100644 webviews/codex-webviews/src/CodexCellEditor/utils/subdivisionUtils.test.ts create mode 100644 webviews/codex-webviews/src/CodexCellEditor/utils/subdivisionUtils.ts diff --git a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx index d14344976..af020ad20 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"; @@ -1847,38 +1848,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] ); 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/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"; From a51505897593b0e63f1d5681526eb6f153d74ecc Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Wed, 22 Apr 2026 22:28:13 -0500 Subject: [PATCH 04/34] Allow renaming milestone subdivisions from the accordion Each keyed subsection in the milestone accordion now carries a hover "rename" affordance. Editing is inline, works on both source and target documents (names are stored per-document in `subdivisionNames`), and an optimistic local cache keeps the new name on screen while the provider refreshes. Numeric ranges remain visible alongside the name so users never lose track of where a subdivision starts and ends. - Skip the affordance entirely for legacy subsections without a `key` (no resolver payload, nothing to persist) so the UI degrades cleanly. - Clearing the input posts an empty `newName`, which the provider handler interprets as "remove override" and falls back to the numeric range. - Pressing Enter saves, Escape cancels; cancel never posts a message. - Add focused tests covering affordance gating, display behaviour, the save/clear/no-op paths, and cancel. Made-with: Cursor --- .../components/MilestoneAccordion.test.tsx | 185 ++++++++++++++ .../components/MilestoneAccordion.tsx | 234 +++++++++++++++--- 2 files changed, 388 insertions(+), 31 deletions(-) diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx index 23eb68b11..a3169d603 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx @@ -758,6 +758,191 @@ 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("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..d1c2b216b 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -92,6 +92,25 @@ export function MilestoneAccordion({ // 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}`]; + }; + // Calculate position and dimensions const calculatePositionAndDimensions = () => { if (isOpen && anchorRef.current) { @@ -577,6 +596,63 @@ 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 @@ -831,6 +907,21 @@ export function MilestoneAccordion({ const isActive = isCurrentMilestone && currentSubsectionIndex === subsectionIdx; + const isEditingThisRow = + editingSubsection?.milestoneIdx === + milestoneIdx && + editingSubsection?.subsectionIdx === + subsectionIdx; + // Prefer the optimistic local cache, then + // provider-supplied name, so renames render + // immediately and survive webview refresh. + const cachedLocalName = getLocalSubsectionName( + milestoneIdx, + subsection.key + ); + const displayName = + cachedLocalName ?? subsection.name; + const canRename = !!subsection.key; return (
+ 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 ? ( + <> + + + + + + + + ) : ( + canRename && ( + + handleSubsectionEditClick( + e, + milestoneIdx, + subsectionIdx, + subsection + ) + } + className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity" + > + + + ) + )} + {!isEditingThisRow && ( + + )} +
); })} From b120e92d193dd1c4096cbb26da650812636fbce7 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Thu, 23 Apr 2026 14:30:29 -0500 Subject: [PATCH 05/34] Add per-subsection delete and per-milestone reset controls (source only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source-side authors can now remove an individual custom subdivision break and, if they want to start over, reset all custom breaks in a milestone back to the default arithmetic chunking. Both actions post `updateMilestoneSubdivisions` with a recomputed placement list derived from the resolver's subdivisions (custom-index- >0 with a stable startCellId). The provider already mirrors the new list to the paired target document, so source and target stay aligned. - The × button only appears for subdivisions whose `source === "custom"` and that carry a `startCellId`, so legacy payloads and the implicit first subdivision are untouched. - "Reset to default breaks" uses an in-place two-click confirmation (arm-then-commit, auto-disarming after 3s) to avoid accidental loss without pulling in a modal. The accessible label flips between "Reset Subdivisions" and "Confirm Reset Subdivisions" so assistive tech announces the armed state. - Neither control is rendered on target documents; placements are source-authoritative and the provider rejects writes from target URIs anyway — the UI just omits the affordance up front. - Extend the lucide-react test mock with the new X/Undo2 icons and add focused coverage for the control visibility and both post shapes. Made-with: Cursor --- .../components/MilestoneAccordion.test.tsx | 181 +++++++++++++++++ .../components/MilestoneAccordion.tsx | 183 ++++++++++++++++-- 2 files changed, 345 insertions(+), 19 deletions(-) diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx index a3169d603..acad726f9 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx @@ -62,6 +62,8 @@ vi.mock("lucide-react", () => ({ Languages: () =>
Languages
, Check: () =>
Check
, RotateCcw: () =>
RotateCcw
, + X: () =>
X
, + Undo2: () =>
Undo2
, })); vi.mock("../../components/ui/icons/MicrophoneIcon", () => ({ @@ -943,6 +945,185 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); + 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("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 d1c2b216b..21164a0ac 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 } from "lucide-react"; import type { Subsection, ProgressPercentages } from "../../lib/types"; import type { MilestoneIndex, MilestoneInfo } from "../../../../../types"; import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; @@ -111,6 +111,89 @@ export function MilestoneAccordion({ 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); + + useEffect(() => { + return () => { + if (resetConfirmTimeoutRef.current !== null) { + window.clearTimeout(resetConfirmTimeoutRef.current); + } + }; + }, []); + + /** + * 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: [], + }, + }); + }; + // Calculate position and dimensions const calculatePositionAndDimensions = () => { if (isOpen && anchorRef.current) { @@ -1005,24 +1088,46 @@ export function MilestoneAccordion({ ) : ( - canRename && ( - - handleSubsectionEditClick( - e, - milestoneIdx, - subsectionIdx, - subsection - ) - } - className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity" - > - - - ) + <> + {canRename && ( + + handleSubsectionEditClick( + e, + milestoneIdx, + subsectionIdx, + subsection + ) + } + className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity" + > + + + )} + {isSourceText && + subsection.source === + "custom" && + subsection.startCellId && ( + + handleDeleteSubsection( + e, + milestoneIdx, + subsection + ) + } + className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity" + > + + + )} + )} {!isEditingThisRow && ( ); })} + {isSourceText && + subsections.some( + (s) => s.source === "custom" + ) && ( +
+ +
+ )} From f1af0055d4b53e0162c27df8d1b1f83f5b0514c9 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Thu, 23 Apr 2026 15:03:04 -0500 Subject: [PATCH 06/34] Add addMilestoneSubdivisionAnchor handler + shared commit pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the save/mirror/refresh pipeline from updateMilestoneSubdivisions into commitMilestoneSubdivisionPlacements so a second entry point — a source-only add-break command that takes a 1-based cellNumber — can reuse the same source-authoritative guarantees (root-cell validation, target mirroring with names stripped, divergence-skip on root mismatch). Tests exercise the new handler via a test-only export of the handler map, covering the happy path, the preserve-names-on-append path, out-of-range rejection, idempotence on re-add, and the non-source rejection guard. Made-with: Cursor --- .../codexCellEditorMessagehandling.ts | 367 ++++++++++++------ src/test/suite/milestoneSubdivisions.test.ts | 165 ++++++++ types/index.d.ts | 18 + 3 files changed, 430 insertions(+), 120 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 6fef76330..3151b5d1c 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -185,6 +185,161 @@ 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 (without names) to the paired target document. + 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) { + const mirroredPlacements = sanitized.map((p) => ({ + startCellId: p.startCellId, + })); + await targetDocument.refreshAuthor(); + targetDocument.updateCellData(targetMilestoneCell.metadata.id, { + subdivisions: mirroredPlacements, + }); + await provider.saveCustomDocument(targetDocument, cancellationToken); + } + } + } + } + } 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); +} + /** * Helper function to get the audio file path for a cell * Checks metadata attachments first, then falls back to filesystem lookup @@ -316,7 +471,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 }) => { @@ -1275,155 +1431,119 @@ 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", + }); + }, - // Placements are source-authoritative. Reject writes originating from a - // target (.codex) document so the source stays the single source of truth. + 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( - "[updateMilestoneSubdivisions] Rejected write from non-source document:", + "[addMilestoneSubdivisionAnchor] Rejected write from non-source document:", document.uri.toString() ); vscode.window.showWarningMessage( - "Subdivision breaks can only be edited from the source file." + "Subdivision breaks can only be added from the source file." ); return; } - const { milestoneIndex, subdivisions: incomingPlacements } = typedEvent.content; + const { milestoneIndex, cellNumber } = typedEvent.content; - const milestoneIndexObj = document.buildMilestoneIndex(); - const milestone = milestoneIndexObj.milestones[milestoneIndex]; - if (!milestone) { - console.error("[updateMilestoneSubdivisions] Milestone not found at index", milestoneIndex); + // 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 update subdivisions: milestone not found at index ${milestoneIndex}` + `Failed to add subdivision break: milestone ${milestoneIndex} has no content cells.` ); return; } - - const sourceMilestoneCell = document.getCellByIndex(milestone.cellIndex); if ( - !sourceMilestoneCell || - sourceMilestoneCell.metadata?.type !== CodexCellTypes.MILESTONE || - !sourceMilestoneCell.metadata?.id + typeof cellNumber !== "number" || + !Number.isFinite(cellNumber) || + cellNumber < 2 || + cellNumber > rootIds.length ) { - console.error( - "[updateMilestoneSubdivisions] Cell at index is not a valid milestone cell", - milestone.cellIndex + 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}.` ); - vscode.window.showErrorMessage("Failed to update subdivisions: 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 callers can surface errors. - const validRootIds = new Set(document.getRootContentCellIdsForMilestone(milestoneIndex)); - const seen = new Set(); - const sanitized: typeof incomingPlacements = []; - for (const placement of incomingPlacements ?? []) { - if (!placement || typeof placement.startCellId !== "string") continue; - if (!validRootIds.has(placement.startCellId)) { - console.warn( - "[updateMilestoneSubdivisions] Dropping unknown startCellId:", - placement.startCellId - ); - continue; - } - if (seen.has(placement.startCellId)) continue; - seen.add(placement.startCellId); - // Intentionally strip names from mirrored placements; names live on - // the separate `subdivisionNames` map so that source and target can - // be named independently (per design spec). - 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 newStartCellId = rootIds[cellNumber - 1]; - 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); - debug( - `[updateMilestoneSubdivisions] Updated source subdivisions for milestone ${milestoneIndex}`, - { count: sanitized.length } + // 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 ); - } catch (error) { - console.error("[updateMilestoneSubdivisions] Failed to update source:", error); vscode.window.showErrorMessage( - `Failed to update subdivisions: ${error instanceof Error ? error.message : String(error)}` + `Failed to add subdivision break: milestone not found at index ${milestoneIndex}` ); return; } - - // Mirror placements (without names) to the paired target document. - 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( - "[updateMilestoneSubdivisions] Target has no milestone at index, skipping mirror:", - milestoneIndex - ); - } else { - // Sanity check: positionally-paired milestones should share - // root content cell IDs. If they diverge, skip the mirror to - // avoid attaching source anchors to 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( - "[updateMilestoneSubdivisions] Source/target milestones diverge; skipping mirror.", - { - milestoneIndex, - sourceLength: sourceRootIds.length, - targetLength: targetRootIds.length, - } - ); - } else { - const targetMilestoneCell = targetDocument.getCellByIndex(targetMilestone.cellIndex); - if (targetMilestoneCell?.metadata?.id) { - // Mirror placements with names stripped — target uses - // its own `subdivisionNames` for local display. - const mirroredPlacements = sanitized.map((p) => ({ - startCellId: p.startCellId, - })); - await targetDocument.refreshAuthor(); - targetDocument.updateCellData(targetMilestoneCell.metadata.id, { - subdivisions: mirroredPlacements, - }); - await provider.saveCustomDocument(targetDocument, cancellationToken); - debug( - "[updateMilestoneSubdivisions] Mirrored subdivisions to target:", - { uri: pairedUri.toString(), count: mirroredPlacements.length } - ); - } - } - } - } - } catch (mirrorError) { - // Mirror failures are non-fatal: the source edit has already - // succeeded. Log and continue so the user can retry later. - console.error( - "[updateMilestoneSubdivisions] Failed to mirror to target:", - mirrorError + 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; } - await sendMilestoneRefreshToWebview(document, webviewPanel, provider); + 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 }) => { @@ -4042,3 +4162,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/test/suite/milestoneSubdivisions.test.ts b/src/test/suite/milestoneSubdivisions.test.ts index b5406c087..ddfe806bf 100644 --- a/src/test/suite/milestoneSubdivisions.test.ts +++ b/src/test/suite/milestoneSubdivisions.test.ts @@ -8,6 +8,7 @@ import { findSubdivisionIndexForRoot, resolveSubdivisions, } from "../../providers/codexCellEditorProvider/utils/subdivisionUtils"; +import { __testOnlyMessageHandlers } from "../../providers/codexCellEditorProvider/codexCellEditorMessagehandling"; import { swallowDuplicateCommandRegistrations, createTempCodexFile, @@ -425,6 +426,170 @@ suite("Milestone Subdivisions Test Suite", () => { ); }); + // ----------------------------------------------------------------- + // 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" + ); + }); + }); + 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. diff --git a/types/index.d.ts b/types/index.d.ts index 109006b31..ada5ca341 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -627,6 +627,24 @@ export type EditorPostMessages = 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; From 2800c2fdcd8125e1e4991923710a4d624846f7f1 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Thu, 23 Apr 2026 15:08:27 -0500 Subject: [PATCH 07/34] =?UTF-8?q?Add=20inline=20"Add=20break=20at=20cell?= =?UTF-8?q?=E2=80=A6"=20form=20to=20milestone=20accordion=20(source=20only?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source-side users can now split a milestone by typing the target cell number directly into the accordion footer instead of having to touch the underlying data. The form validates the number against the milestone's root-cell range before posting addMilestoneSubdivisionAnchor, surfaces an inline error on out-of-range input, and respects Escape/Cancel to close without committing. Hidden entirely on target documents and on milestones with fewer than 2 cells. Made-with: Cursor --- .../components/MilestoneAccordion.test.tsx | 189 ++++++++++++ .../components/MilestoneAccordion.tsx | 272 +++++++++++++++--- 2 files changed, 424 insertions(+), 37 deletions(-) diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx index acad726f9..eacc378db 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx @@ -64,6 +64,7 @@ vi.mock("lucide-react", () => ({ RotateCcw: () =>
RotateCcw
, X: () =>
X
, Undo2: () =>
Undo2
, + Plus: () =>
Plus
, })); vi.mock("../../components/ui/icons/MicrophoneIcon", () => ({ @@ -1124,6 +1125,194 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); + 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("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("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 21164a0ac..dfc1d91c8 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, X, Undo2 } from "lucide-react"; +import { Languages, Check, RotateCcw, X, Undo2, Plus } from "lucide-react"; import type { Subsection, ProgressPercentages } from "../../lib/types"; import type { MilestoneIndex, MilestoneInfo } from "../../../../../types"; import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; @@ -116,6 +116,14 @@ export function MilestoneAccordion({ 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) { @@ -124,6 +132,14 @@ export function MilestoneAccordion({ }; }, []); + // 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 @@ -194,6 +210,72 @@ export function MilestoneAccordion({ }); }; + /** + * 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) { @@ -1158,46 +1240,162 @@ export function MilestoneAccordion({ ); })} - {isSourceText && - subsections.some( + {isSourceText && (() => { + const maxCellNumber = + getMaxCellNumberForMilestone( + subsections + ); + const canAddBreak = maxCellNumber >= 2; + const isFormOpen = + addBreakMilestoneIdx === milestoneIdx; + const hasCustomBreaks = subsections.some( (s) => s.source === "custom" - ) && ( -
- + + {addBreakError && ( + + {addBreakError} + + )} + + ) : ( + canAddBreak && ( + + ) + )} + {hasCustomBreaks && !isFormOpen && ( + + ? "Click again to confirm" + : "Reset to default breaks"} + + )}
- )} + ); + })()} From 9e88bae06894c588c595a21eacd70f67d7db6900 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Thu, 23 Apr 2026 16:05:06 -0500 Subject: [PATCH 08/34] Add useSubdivisionNumberLabels setting to force numeric subsection labels Introduces codex-editor-extension.useSubdivisionNumberLabels (default false). When enabled, the milestone accordion always shows the numeric cell range instead of a user-assigned subdivision name. The provider broadcasts the current value on initial pagination load and whenever the setting changes. Made-with: Cursor --- package.json | 6 +++ .../codexCellEditorMessagehandling.ts | 5 ++ .../codexCellEditorProvider.ts | 28 +++++++++++ types/index.d.ts | 15 ++++++ .../ChapterNavigationHeader.tsx | 7 +++ .../src/CodexCellEditor/CodexCellEditor.tsx | 17 +++++++ .../components/MilestoneAccordion.test.tsx | 48 +++++++++++++++++++ .../components/MilestoneAccordion.tsx | 18 ++++++- 8 files changed, 142 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3ad7be401..134ab0f31 100644 --- a/package.json +++ b/package.json @@ -947,6 +947,12 @@ "maximum": 200, "description": "Number of cells to display per page when a chapter has many cells. Helps with performance for large chapters." }, + "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.useOnlyValidatedExamples": { "title": "Use Only Validated Examples", "type": "boolean", diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 3151b5d1c..b7cb3e0b4 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -159,6 +159,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, @@ -171,6 +175,7 @@ export async function sendMilestoneRefreshToWebview( username: username, validationCount: validationCount, validationCountAudio: validationCountAudio, + useSubdivisionNumberLabels, }); safePostMessageToPanel(webviewPanel, { diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index cad362426..fb5bc7a50 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -123,6 +123,16 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { + safePostMessageToPanel(panel, { + type: "updateSubdivisionLabelPreference", + useSubdivisionNumberLabels: newPref, + }); + }); + } }); this.context.subscriptions.push(configurationChangeDisposable); @@ -886,6 +912,7 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider; 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 af020ad20..b2976bc9f 100755 --- a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx @@ -242,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()); @@ -2333,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) + ); } }, [] @@ -3216,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 eacc378db..592f035b0 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx @@ -1282,6 +1282,54 @@ describe("MilestoneAccordion - Milestone Editing", () => { 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, diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx index dfc1d91c8..529906574 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -46,6 +46,13 @@ 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; } export function MilestoneAccordion({ @@ -63,6 +70,7 @@ export function MilestoneAccordion({ calculateSubsectionProgress, requestSubsectionProgress, vscode, + useSubdivisionNumberLabels = false, }: MilestoneAccordionProps) { // Layout constants const DROPDOWN_MAX_HEIGHT_VIEWPORT_PERCENT = 60; // 60vh @@ -1084,8 +1092,14 @@ export function MilestoneAccordion({ milestoneIdx, subsection.key ); - const displayName = - cachedLocalName ?? subsection.name; + // Respect the workspace toggle: even if a name exists + // (local override or provider-resolved), we force the + // numeric range to be the visible label by dropping + // displayName. Rename UI still reflects the stored name + // so the user can edit what's actually persisted. + const displayName = useSubdivisionNumberLabels + ? undefined + : cachedLocalName ?? subsection.name; const canRename = !!subsection.key; return ( From 6e0d73423edc4416dd8b21003c555efa1b2c2c1b Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Thu, 23 Apr 2026 16:09:30 -0500 Subject: [PATCH 09/34] Surface cellsPerPage and useSubdivisionNumberLabels in Interface Settings Adds a Pagination & Subdivisions section to the Interface Settings webview so users can adjust the default milestone page size and toggle whether subdivisions render as numeric ranges instead of their names. The panel listens for configuration changes and rebroadcasts initial data when either setting is updated from elsewhere. Made-with: Cursor --- package.json | 3 +- src/interfaceSettings/interfaceSettings.ts | 49 +++++++++ .../src/InterfaceSettings/index.tsx | 100 ++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 134ab0f31..2e978790f 100644 --- a/package.json +++ b/package.json @@ -941,11 +941,12 @@ "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", diff --git a/src/interfaceSettings/interfaceSettings.ts b/src/interfaceSettings/interfaceSettings.ts index 04faa39c1..58f80ff4a 100644 --- a/src/interfaceSettings/interfaceSettings.ts +++ b/src/interfaceSettings/interfaceSettings.ts @@ -62,11 +62,18 @@ 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 + ); panel.webview.postMessage({ command: "init", data: { highlightSearchResults, + cellsPerPage, + useSubdivisionNumberLabels, }, }); }; @@ -99,6 +106,48 @@ 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; + } } }); + + // 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") + ) { + sendInit(); + } + }); + + panel.onDidDispose(() => { + configListener.dispose(); + }); } diff --git a/webviews/codex-webviews/src/InterfaceSettings/index.tsx b/webviews/codex-webviews/src/InterfaceSettings/index.tsx index 8c08f6a86..a123409c4 100644 --- a/webviews/codex-webviews/src/InterfaceSettings/index.tsx +++ b/webviews/codex-webviews/src/InterfaceSettings/index.tsx @@ -49,6 +49,13 @@ 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); + useEffect(() => { const handler = (event: MessageEvent) => { const message = event.data; @@ -56,6 +63,13 @@ 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); + } } }; window.addEventListener("message", handler); @@ -81,6 +95,33 @@ 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, + }); + }; + return (
@@ -262,6 +303,65 @@ function InterfaceSettingsApp() {
+ {/* Pagination & Subdivisions Section */} +
+
+ + Pagination & Subdivisions +
+ +
+ {/* Cells per page */} +
+
+
Cells per page
+
+ Default page size for milestones without custom breaks + (between {CELLS_PER_PAGE_MIN} and {CELLS_PER_PAGE_MAX}). +
+
+ + 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 */} +
+
+
+ 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. +
+
+ +
+
+
+ {/* Search Settings Section */}
From 8d902ba04fcb4fb495c0164ce4c066900a10ca60 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Thu, 23 Apr 2026 20:38:36 -0500 Subject: [PATCH 10/34] Refresh paired target webview after source-side subdivision placement edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user adds, removes, or resets subdivision breaks on a source document, the placement mirror was already being persisted to the paired target file but the open target webview was never told to re-render — users had to close and reopen the target tab to see the change. This threads the existing milestone-refresh path through to the target panel so the change appears immediately. The cost per source-side break edit is one extra postMessage plus a milestone-index rebuild on the target (cached and invalidated by updateCellData), which is negligible even on older hardware. When no target webview is open the refresh is skipped silently and the target picks up the change on next load via the persisted file. Subdivision name edits remain per-side by design and don't trigger a target refresh. Made-with: Cursor --- .../codexCellEditorMessagehandling.ts | 30 +++++++++++++++++++ .../codexCellEditorProvider.ts | 10 +++++++ 2 files changed, 40 insertions(+) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index b7cb3e0b4..2421376ff 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -293,6 +293,11 @@ async function commitMilestoneSubdivisionPlacements({ } // Mirror placements (without names) to the paired target document. + // 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) { @@ -332,6 +337,7 @@ async function commitMilestoneSubdivisionPlacements({ subdivisions: mirroredPlacements, }); await provider.saveCustomDocument(targetDocument, cancellationToken); + mirroredTargetDocument = targetDocument; } } } @@ -343,6 +349,30 @@ async function commitMilestoneSubdivisionPlacements({ } 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 + ); + } + } + } } /** diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index fb5bc7a50..6420937b3 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -1731,6 +1731,16 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider Date: Thu, 23 Apr 2026 20:38:47 -0500 Subject: [PATCH 11/34] Move subdivision edit affordances behind a gear/settings toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The milestone accordion now opens in a read-only baseline: the only edit-related control in the header is a gear icon. Clicking the gear reveals the title pencil, surfaces the per-subsection rename pencils (always visible — no more hover-only reveal), and unhides the source- only "Add break…" / "Reset" footers. Re-clicking the gear collapses the affordances back, and reopening the dropdown always starts from the read-only baseline so the gear-on state is never sticky across sessions. The "Add break at cell" placeholder also drops its compact "2–N" range hint in favor of a single illustrative number, matching the underlying input semantics (one cell number, not a range). Tests opt back into settings mode by default via a new `initialSettingsMode` prop so the existing edit/rename/break test suites keep exercising those flows without first having to click the gear; three new tests cover the gear toggle itself. Made-with: Cursor --- .../components/MilestoneAccordion.test.tsx | 143 ++++++++++++++++++ .../components/MilestoneAccordion.tsx | 85 +++++++++-- 2 files changed, 213 insertions(+), 15 deletions(-) diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx index 592f035b0..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) => ( @@ -177,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(); @@ -1361,6 +1369,141 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); + 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 529906574..83ab6ecf0 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -53,6 +53,14 @@ interface MilestoneAccordionProps { * 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({ @@ -71,6 +79,7 @@ export function MilestoneAccordion({ requestSubsectionProgress, vscode, useSubdivisionNumberLabels = false, + initialSettingsMode = false, }: MilestoneAccordionProps) { // Layout constants const DROPDOWN_MAX_HEIGHT_VIEWPORT_PERCENT = 60; // 60vh @@ -97,6 +106,10 @@ 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>({}); @@ -406,7 +419,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) @@ -919,15 +939,45 @@ export function MilestoneAccordion({ ) : ( - - - + <> + {/* Pencil only appears once the user has opened + settings mode via the gear, so the default + accordion view stays read-only. */} + {isSettingsMode && ( + + + + )} + { + e.stopPropagation(); + setIsSettingsMode((prev) => !prev); + }} + aria-pressed={isSettingsMode} + > + + + )}
) : ( <> - {canRename && ( + {/* 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 && ( )} - {isSourceText && + {isSettingsMode && + isSourceText && subsection.source === "custom" && subsection.startCellId && ( @@ -1218,7 +1274,6 @@ export function MilestoneAccordion({ subsection ) } - className="opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity" > @@ -1254,7 +1309,7 @@ export function MilestoneAccordion({
); })} - {isSourceText && (() => { + {isSourceText && isSettingsMode && (() => { const maxCellNumber = getMaxCellNumberForMilestone( subsections @@ -1318,7 +1373,7 @@ export function MilestoneAccordion({ aria-invalid={ !!addBreakError } - placeholder={`2–${maxCellNumber}`} + 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 && ( + + {addBreakError} + + )} + + ) : ( + canAddBreak && ( + + ) + )} + {hasCustomBreaks && !isFormOpen && ( - ) - )} - {hasCustomBreaks && !isFormOpen && ( - - )} - - ); - })()} + ? "Click again to confirm" + : "Reset to default breaks"} + + )} + + ); + })()} From cc2ff477e12cd1171eeb72d3b173ffbe60c53940 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Fri, 24 Apr 2026 01:30:14 -0500 Subject: [PATCH 17/34] MilestoneAccordion: fix rename click behavior Ensure the clicked milestone expands before entering edit mode, and move the rename affordance into the milestone row. --- .../components/MilestoneAccordion.tsx | 72 +++++++++++-------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx index 3ad22a6fa..ad567b4fb 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -713,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) => { @@ -945,20 +949,6 @@ export function MilestoneAccordion({ ) : ( <> - {/* Pencil only appears once the user has opened - settings mode via the gear, so the default - accordion view stays read-only. */} - {isSettingsMode && ( - - - - )}
+ {isSettingsMode && ( + <> + + beginEditMilestone( + e, + milestoneIdx + ) + } + > + + + + + )}
@@ -1451,7 +1467,7 @@ export function MilestoneAccordion({ milestoneIdx ) } - className="flex items-center gap-1 text-xs px-2 py-1 rounded text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] hover:bg-secondary transition-colors" + className="flex items-center gap-1 text-xs pl-0 pr-2 py-1 rounded text-[var(--vscode-descriptionForeground)] hover:text-[var(--vscode-foreground)] hover:bg-secondary transition-colors" > Add break… From 99869c86331b7ad4aa0f0d820fc7604485a55e8e Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Wed, 29 Apr 2026 22:57:39 -0500 Subject: [PATCH 18/34] Fix tests on milestone subdivisions --- package-lock.json | 54 ++++++++++++-------- src/test/suite/milestoneSubdivisions.test.ts | 18 +++---- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index e6af9de62..bb81ea4b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2305,6 +2305,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.609.0.tgz", "integrity": "sha512-0bNPAyPdkWkS9EGB2A9BZDkBNrnVCBzk5lYRezoT4K3/gi9w1DTYH5tuRdwaTZdxW19U1mq7CV0YJJARKO1L9Q==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -2358,6 +2359,7 @@ "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.609.0.tgz", "integrity": "sha512-A0B3sDKFoFlGo8RYRjDBWHXpbgirer2bZBkCIzhSPHc1vOFHt/m2NcUoE2xnBKXJFrptL1xDkvo1P+XYp/BfcQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -4982,7 +4984,6 @@ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -5025,7 +5026,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -5066,7 +5066,6 @@ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -5084,7 +5083,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -5151,7 +5149,6 @@ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" @@ -5166,7 +5163,6 @@ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", @@ -5260,7 +5256,6 @@ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -5271,7 +5266,6 @@ "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" @@ -6017,6 +6011,7 @@ "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -6053,6 +6048,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blit/-/plugin-blit-0.22.12.tgz", "integrity": "sha512-xslz2ZoFZOPLY8EZ4dC29m168BtDx95D6K80TzgUi8gqT7LY6CsajWO0FAxDwHz6h0eomHMfyGX0stspBrTKnQ==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6065,6 +6061,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-blur/-/plugin-blur-0.22.12.tgz", "integrity": "sha512-S0vJADTuh1Q9F+cXAwFPlrKWzDj2F9t/9JAbUvaaDuivpyWuImEKXVz5PUZw2NbpuSHjwssbTpOZ8F13iJX4uw==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6089,6 +6086,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-color/-/plugin-color-0.22.12.tgz", "integrity": "sha512-xImhTE5BpS8xa+mAN6j4sMRWaUgUDLoaGHhJhpC+r7SKKErYDR0WQV4yCE4gP+N0gozD0F3Ka1LUSaMXrn7ZIA==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12", "tinycolor2": "^1.6.0" @@ -6132,6 +6130,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-crop/-/plugin-crop-0.22.12.tgz", "integrity": "sha512-FNuUN0OVzRCozx8XSgP9MyLGMxNHHJMFt+LJuFjn1mu3k0VQxrzqbN06yIl46TVejhyAhcq5gLzqmSCHvlcBVw==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6255,6 +6254,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-resize/-/plugin-resize-0.22.12.tgz", "integrity": "sha512-3NyTPlPbTnGKDIbaBgQ3HbE6wXbAlFfxHVERmrbqAi8R3r6fQPxpCauA8UVDnieg5eo04D0T8nnnNIX//i/sXg==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6267,6 +6267,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-rotate/-/plugin-rotate-0.22.12.tgz", "integrity": "sha512-9YNEt7BPAFfTls2FGfKBVgwwLUuKqy+E8bDGGEsOqHtbuhbshVGxN2WMZaD4gh5IDWvR+emmmPPWGgaYNYt1gA==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6282,6 +6283,7 @@ "resolved": "https://registry.npmjs.org/@jimp/plugin-scale/-/plugin-scale-0.22.12.tgz", "integrity": "sha512-dghs92qM6MhHj0HrV2qAwKPMklQtjNpoYgAB94ysYpsXslhRTiPisueSIELRwZGEr0J0VUxpUY7HgJwlSIgGZw==", "license": "MIT", + "peer": true, "dependencies": { "@jimp/utils": "^0.22.12" }, @@ -6418,7 +6420,6 @@ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -8275,6 +8276,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -8472,6 +8474,7 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -9976,6 +9979,7 @@ "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10502,6 +10506,7 @@ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -10934,6 +10939,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11821,8 +11827,7 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cookie": { "version": "0.7.2", @@ -12508,7 +12513,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1400418.tgz", "integrity": "sha512-U8j75zDOXF8IP3o0Cgb7K4tFA9uUHEOru2Wx64+EUqL4LNOh9dRe1i8WKR1k3mSpjcCe3aIkTDvEwq0YkI4hfw==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff": { "version": "7.0.0", @@ -12820,6 +12826,7 @@ "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -13050,6 +13057,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -14421,7 +14429,6 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -16386,7 +16393,6 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -16829,7 +16835,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -16866,7 +16871,6 @@ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -19645,6 +19649,7 @@ "dev": true, "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -21575,7 +21580,6 @@ "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -21586,8 +21590,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -22691,6 +22694,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -24213,7 +24217,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/turndown": { "version": "7.2.4", @@ -24284,6 +24289,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -27232,6 +27238,7 @@ "integrity": "sha512-SyrSVpygEdPzvgpapVZRQCy8XIOecadp56bPQewpfSfo9ypB6wdOUkx13NBu2ANDlUAtJX7KaLJpTtywVHNlVw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "^22.2.0", "@wdio/config": "8.46.0", @@ -27312,6 +27319,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -27361,6 +27369,7 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -27437,6 +27446,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -27681,6 +27691,7 @@ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -27791,8 +27802,7 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/yargs": { "version": "17.7.2", diff --git a/src/test/suite/milestoneSubdivisions.test.ts b/src/test/suite/milestoneSubdivisions.test.ts index f27c8c974..40eec1a9d 100644 --- a/src/test/suite/milestoneSubdivisions.test.ts +++ b/src/test/suite/milestoneSubdivisions.test.ts @@ -64,7 +64,7 @@ suite("Milestone Subdivisions Test Suite", () => { // --------------------------------------------------------------------------- suite("resolveSubdivisions()", () => { - const ids = (count: number) => Array.from({ length: count }, (_, i) => `c${i + 1}`); + const ids = (count: number) => Array.from({ length: count }, (_, i) => `c${i}`); test("returns empty array when no root cells", () => { const result = resolveSubdivisions({ @@ -86,7 +86,7 @@ suite("Milestone Subdivisions Test Suite", () => { ); assert.strictEqual(result[0].source, "auto"); assert.strictEqual(result[0].key, FIRST_SUBDIVISION_KEY); - assert.strictEqual(result[1].startCellId, "c51"); + assert.strictEqual(result[1].startCellId, "c50"); }); test("single page when count <= pageSize", () => { @@ -106,11 +106,11 @@ suite("Milestone Subdivisions Test Suite", () => { assert.strictEqual(result.length, 2); assert.deepStrictEqual( [result[0].startRootIndex, result[0].endRootIndex], - [0, 5], + [0, 6], ); assert.deepStrictEqual( [result[1].startRootIndex, result[1].endRootIndex], - [5, 10], + [6, 10], ); assert.strictEqual(result[1].name, "Second Half"); assert.strictEqual(result[1].source, "custom"); @@ -131,7 +131,7 @@ suite("Milestone Subdivisions Test Suite", () => { assert.strictEqual(result.length, 4); assert.deepStrictEqual( result.map((s) => s.startRootIndex), - [0, 2, 5, 7], + [0, 3, 6, 8], ); }); @@ -149,7 +149,7 @@ suite("Milestone Subdivisions Test Suite", () => { 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], + [0, 3, 3, 5], ); }); @@ -166,11 +166,11 @@ suite("Milestone Subdivisions Test Suite", () => { assert.strictEqual(result.length, 2); }); - test("placement at c1 names the implicit first subdivision rather than creating a new one", () => { + test("placement at c0 names the implicit first subdivision rather than creating a new one", () => { const rootIds = ids(4); const result = resolveSubdivisions({ rootContentCellIds: rootIds, - placements: [{ startCellId: "c1", name: "Intro" }], + placements: [{ startCellId: "c0", name: "Intro" }], cellsPerPage: 50, }); assert.strictEqual(result.length, 1, "No actual break, just a name on the first subdivision"); @@ -203,7 +203,7 @@ suite("Milestone Subdivisions Test Suite", () => { const rootIds = ids(10); const subs = resolveSubdivisions({ rootContentCellIds: rootIds, - placements: [{ startCellId: "c4" }, { startCellId: "c8" }], + placements: [{ startCellId: "c3" }, { startCellId: "c7" }], cellsPerPage: 50, }); assert.strictEqual(findSubdivisionIndexForRoot(subs, 0), 0); From 8ba8cc623142a2f1486b926baa812282f163730b Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Tue, 5 May 2026 11:02:37 -0500 Subject: [PATCH 19/34] Fix MilestoneAccordion tests for new per-row rename UI The /build CI was failing because the webview test suite couldn't load MilestoneAccordion: the lucide-react mock explicitly listed icons and silently broke the moment the component imported a new one (Trash2). Even after fixing the mock, ~30 assertions still pointed at the old single-pencil "Edit Milestone" label that no longer exists, and the new per-row "Rename Milestone" pencils caused getByLabelText to throw on multi-element matches. Two coupled fixes, one outcome: 1. Standardize rename-flow aria-labels around the user-facing nouns: - Milestone rename: "Rename Milestone" / "Save Milestone Rename" / "Cancel Milestone Rename" (was "Edit/Save Milestone" + "Revert Changes"). - Milestone subdivision rename: "Rename/Save/Cancel Milestone Subdivision Rename" (was "Rename/Save/Cancel Subsection [Name]"). Non-rename actions (gear, add/remove break, reset) keep their existing names. 2. Make the test queries match the new UI: - vi.mock("lucide-react") now uses importOriginal so newly imported icons don't silently break the suite again. - Per-milestone rename pencils are looked up via a small helper that scopes within the row's data-testid="accordion-item-{idx}", so tests read as "Chapter 1's pencil" rather than "the first getByLabelText match". - Describe blocks renamed Edit Mode -> Milestone Rename and Subsection Rename -> Milestone Subdivision Rename to match the user-facing language. --- .../components/MilestoneAccordion.test.tsx | 298 ++++++++++-------- .../components/MilestoneAccordion.tsx | 20 +- 2 files changed, 176 insertions(+), 142 deletions(-) diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx index 97b9f3000..401eb3a24 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, screen, fireEvent, act } from "@testing-library/react"; +import { render, screen, fireEvent, act, within } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import "@testing-library/jest-dom/vitest"; import { MilestoneAccordion } from "./MilestoneAccordion"; @@ -59,15 +59,14 @@ vi.mock("./ProgressDots", () => ({ ProgressDots: () =>
ProgressDots
, })); -// Mock icons -vi.mock("lucide-react", () => ({ - Languages: () =>
Languages
, - Check: () =>
Check
, - RotateCcw: () =>
RotateCcw
, - X: () =>
X
, - Undo2: () =>
Undo2
, - Plus: () =>
Plus
, -})); +// Mock icons. Use importOriginal so any new icon imported by the component +// (e.g. Trash2) is automatically available in tests without having to be +// re-listed here. The previous explicit-list approach silently broke tests +// every time a new icon was introduced. +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual }; +}); vi.mock("../../components/ui/icons/MicrophoneIcon", () => ({ default: () =>
Microphone
, @@ -157,6 +156,28 @@ describe("MilestoneAccordion - Milestone Editing", () => { vi.restoreAllMocks(); }); + /** + * Scope-helpers for per-milestone queries. + * + * The accordion renders one row per milestone, each wrapped in + * `data-testid="accordion-item-${idx}"` (see the AccordionItem mock above). + * The "Rename Milestone" pencil now lives on every row, so an unscoped + * `getByLabelText("Rename Milestone")` would match N elements and throw. + * Tests almost always exercise the *current* milestone (idx 0 by default), + * so we expose a small helper that scopes the lookup to that row. + * + * Use `getRenameMilestoneButton(idx)` for "must exist" assertions and + * `queryRenameMilestoneButton(idx)` for "must not exist" assertions on a + * specific row. For "no rename pencils anywhere" (e.g. settings mode off) + * use `screen.queryAllByLabelText("Rename Milestone")`. + */ + const getMilestoneRow = (milestoneIdx: number = 0): HTMLElement => + screen.getByTestId(`accordion-item-${milestoneIdx}`); + const getRenameMilestoneButton = (milestoneIdx: number = 0): HTMLElement => + within(getMilestoneRow(milestoneIdx)).getByLabelText("Rename Milestone"); + const queryRenameMilestoneButton = (milestoneIdx: number = 0): HTMLElement | null => + within(getMilestoneRow(milestoneIdx)).queryByLabelText("Rename Milestone"); + function renderMilestoneAccordion( props: Partial> = {} ) { @@ -179,47 +200,49 @@ 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`. + // Most rename-flow tests assert directly against the milestone + // pencil, per-milestone-subdivision pencils, and add-break + // controls. Those affordances are now gated behind the + // gear/settings toggle, so we open the accordion already in + // settings mode by default and let the dedicated gear-toggle + // tests override this with `false`. initialSettingsMode: true, }; return render(); } - describe("Edit Mode - Starting Edit", () => { - it("should enter edit mode when edit button is clicked", async () => { + describe("Milestone Rename - Starting", () => { + it("swaps the dropdown header from gear → save/cancel and reveals the rename input", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); - expect(editButton).toBeInTheDocument(); + const renameButton = getRenameMilestoneButton(); + expect(renameButton).toBeInTheDocument(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); - // Should show input field + // Header now hosts the rename input, prefilled with the milestone's + // current display value. const input = screen.getByDisplayValue("Chapter 1"); expect(input).toBeInTheDocument(); expect(input.tagName).toBe("INPUT"); - // Should show save and revert buttons - expect(screen.getByLabelText("Save Milestone")).toBeInTheDocument(); - expect(screen.getByLabelText("Revert Changes")).toBeInTheDocument(); - - // Edit button should not be visible - expect(screen.queryByLabelText("Edit Milestone")).not.toBeInTheDocument(); + // Save + Cancel replace the gear in the header during a rename. + expect(screen.getByLabelText("Save Milestone Rename")).toBeInTheDocument(); + expect(screen.getByLabelText("Cancel Milestone Rename")).toBeInTheDocument(); + expect( + screen.queryByLabelText("Toggle Milestone Settings") + ).not.toBeInTheDocument(); }); it("should initialize input with current milestone value", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -229,9 +252,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should show input field when entering edit mode", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); // Input should be visible and editable @@ -241,14 +264,14 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Edit Mode - Saving Changes", () => { + describe("Milestone Rename - Saving Changes", () => { it("should save milestone when save button is clicked with valid value", async () => { renderMilestoneAccordion(); // Enter edit mode - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); // Change the value @@ -258,7 +281,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); // Click save - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -282,9 +305,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should trim whitespace when saving", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -292,7 +315,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: " Trimmed Chapter 1 " } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -309,9 +332,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should not save if value is empty after trimming", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -319,7 +342,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: " " } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); expect(saveButton).toBeDisabled(); await act(async () => { @@ -333,12 +356,12 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should not save if value hasn't changed", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); expect(saveButton).toBeDisabled(); await act(async () => { @@ -352,9 +375,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should update local cache immediately after saving", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -362,7 +385,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Cached Chapter 1" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -376,9 +399,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should handle save when milestone index is valid", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -386,7 +409,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Valid Save" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -402,13 +425,13 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Edit Mode - Reverting Changes", () => { + describe("Milestone Rename - Reverting Changes", () => { it("should revert to original value when revert button is clicked", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -416,7 +439,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Changed Value" } }); }); - const revertButton = screen.getByLabelText("Revert Changes"); + const revertButton = screen.getByLabelText("Cancel Milestone Rename"); await act(async () => { fireEvent.click(revertButton); }); @@ -431,9 +454,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should not send postMessage when reverting", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -441,7 +464,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Changed Value" } }); }); - const revertButton = screen.getByLabelText("Revert Changes"); + const revertButton = screen.getByLabelText("Cancel Milestone Rename"); await act(async () => { fireEvent.click(revertButton); }); @@ -451,13 +474,13 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Edit Mode - Keyboard Shortcuts", () => { + describe("Milestone Rename - Keyboard Shortcuts", () => { it("should save when Enter key is pressed", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -481,9 +504,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should revert when Escape key is pressed", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -508,9 +531,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should prevent default behavior for Enter key", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -539,9 +562,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should prevent default behavior for Escape key", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -565,14 +588,14 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Edit Mode - Local Cache", () => { + describe("Milestone Rename - Local Cache", () => { it("should use cached value when displaying previously edited milestone", async () => { renderMilestoneAccordion(); // Edit and save milestone 0 - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -580,7 +603,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Saved Chapter 1" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -597,9 +620,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { renderMilestoneAccordion(); // Edit and save milestone 0 - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -607,7 +630,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Cached Chapter 1" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -622,13 +645,13 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Edit Mode - Button States", () => { + describe("Milestone Rename - Button States", () => { it("should disable save button when value is empty", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -636,16 +659,16 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); expect(saveButton).toBeDisabled(); }); it("should disable save button when value is only whitespace", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -653,28 +676,28 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: " " } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); expect(saveButton).toBeDisabled(); }); it("should disable save button when value hasn't changed", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); expect(saveButton).toBeDisabled(); }); it("should enable save button when value has changed", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -682,42 +705,42 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "New Value" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); expect(saveButton).not.toBeDisabled(); }); it("should always enable revert button", async () => { renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); - const revertButton = screen.getByLabelText("Revert Changes"); + const revertButton = screen.getByLabelText("Cancel Milestone Rename"); expect(revertButton).not.toBeDisabled(); }); }); - describe("Edit Mode - Source Text Mode", () => { - it("should show edit button when isSourceText is true", () => { + describe("Milestone Rename - Source Text Mode", () => { + it("renders the Rename Milestone pencil on source documents", () => { renderMilestoneAccordion({ isSourceText: true }); - expect(screen.getByLabelText("Edit Milestone")).toBeInTheDocument(); + expect(getRenameMilestoneButton()).toBeInTheDocument(); }); - it("should show edit button when isSourceText is false", () => { + it("renders the Rename Milestone pencil on target documents", () => { renderMilestoneAccordion({ isSourceText: false }); - expect(screen.getByLabelText("Edit Milestone")).toBeInTheDocument(); + expect(getRenameMilestoneButton()).toBeInTheDocument(); }); it("should allow editing milestones in source files", async () => { renderMilestoneAccordion({ isSourceText: true }); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -725,7 +748,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Source Chapter 1" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -743,9 +766,9 @@ describe("MilestoneAccordion - Milestone Editing", () => { it("should allow editing milestones in target files", async () => { renderMilestoneAccordion({ isSourceText: false }); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -753,7 +776,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Target Chapter 1" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); @@ -769,7 +792,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Subsection Rename", () => { + describe("Milestone Subdivision Rename", () => { const createSubsectionWithKey = ( id: string, label: string, @@ -786,7 +809,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { source: "custom", }); - it("renders rename button only for subsections that carry a key", async () => { + it("renders rename button only for milestone subdivisions that carry a key", async () => { mockGetSubsectionsForMilestone = vi.fn((milestoneIdx: number) => [ createSubsectionWithKey( `s-${milestoneIdx}-0`, @@ -808,7 +831,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { getSubsectionsForMilestone: mockGetSubsectionsForMilestone, }); - const renameButtons = await screen.findAllByLabelText("Rename Subsection"); + const renameButtons = await screen.findAllByLabelText("Rename Milestone Subdivision"); // Two keyed subsections → two rename affordances; the legacy one is omitted. expect(renameButtons).toHaveLength(2); }); @@ -827,7 +850,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { expect(screen.getByText("1-5")).toBeInTheDocument(); }); - it("posts updateMilestoneSubdivisionName when the subsection rename is saved", async () => { + it("posts updateMilestoneSubdivisionName when the milestone subdivision rename is saved", async () => { mockGetSubsectionsForMilestone = vi.fn((milestoneIdx: number) => [ createSubsectionWithKey(`s-${milestoneIdx}-0`, "1-5", "v1"), ]); @@ -836,7 +859,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { getSubsectionsForMilestone: mockGetSubsectionsForMilestone, }); - const renameBtn = await screen.findByLabelText("Rename Subsection"); + const renameBtn = await screen.findByLabelText("Rename Milestone Subdivision"); await act(async () => { fireEvent.click(renameBtn); }); @@ -847,7 +870,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Opening" } }); }); - const saveBtn = screen.getByLabelText("Save Subsection Name"); + const saveBtn = screen.getByLabelText("Save Milestone Subdivision Rename"); await act(async () => { fireEvent.click(saveBtn); }); @@ -871,7 +894,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { getSubsectionsForMilestone: mockGetSubsectionsForMilestone, }); - const renameBtn = await screen.findByLabelText("Rename Subsection"); + const renameBtn = await screen.findByLabelText("Rename Milestone Subdivision"); await act(async () => { fireEvent.click(renameBtn); }); @@ -881,7 +904,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "" } }); }); - const saveBtn = screen.getByLabelText("Save Subsection Name"); + const saveBtn = screen.getByLabelText("Save Milestone Subdivision Rename"); await act(async () => { fireEvent.click(saveBtn); }); @@ -905,12 +928,12 @@ describe("MilestoneAccordion - Milestone Editing", () => { getSubsectionsForMilestone: mockGetSubsectionsForMilestone, }); - const renameBtn = await screen.findByLabelText("Rename Subsection"); + const renameBtn = await screen.findByLabelText("Rename Milestone Subdivision"); await act(async () => { fireEvent.click(renameBtn); }); - const saveBtn = screen.getByLabelText("Save Subsection Name"); + const saveBtn = screen.getByLabelText("Save Milestone Subdivision Rename"); await act(async () => { fireEvent.click(saveBtn); }); @@ -930,7 +953,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { getSubsectionsForMilestone: mockGetSubsectionsForMilestone, }); - const renameBtn = await screen.findByLabelText("Rename Subsection"); + const renameBtn = await screen.findByLabelText("Rename Milestone Subdivision"); await act(async () => { fireEvent.click(renameBtn); }); @@ -940,7 +963,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "Something Else" } }); }); - const cancelBtn = screen.getByLabelText("Cancel Rename"); + const cancelBtn = screen.getByLabelText("Cancel Milestone Subdivision Rename"); await act(async () => { fireEvent.click(cancelBtn); }); @@ -954,7 +977,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Subsection Delete and Reset (source only)", () => { + describe("Milestone Subdivision Delete and Reset (source only)", () => { const makeSubsection = ( id: string, label: string, @@ -1020,7 +1043,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { makeSubsection("s-2", "16-30", "v16", "custom", "v16", "Final"), ]; - it("shows remove button only for custom subsections in source", async () => { + it("shows remove button only for custom milestone subdivisions in source", async () => { renderMilestoneAccordion({ isSourceText: true, milestoneIndex: createIndexWithSubdivisions(), @@ -1374,11 +1397,16 @@ describe("MilestoneAccordion - Milestone Editing", () => { 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(); + // in the header, every milestone's rename pencil is gone, and the + // per-subdivision pencils + add-break / reset footers stay hidden. + // Use queryAllByLabelText so the assertion is precise about the + // *count* of pencils (zero) without throwing when there happen + // to be multiple rows. + expect(screen.queryAllByLabelText("Rename Milestone")).toHaveLength(0); + expect( + screen.queryAllByLabelText("Rename Milestone Subdivision") + ).toHaveLength(0); + expect(screen.queryAllByLabelText("Add Subdivision Break")).toHaveLength(0); const gearButton = screen.getByLabelText("Toggle Milestone Settings"); expect(gearButton).toHaveAttribute("aria-pressed", "false"); @@ -1386,17 +1414,20 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.click(gearButton); }); - expect(screen.getByLabelText("Edit Milestone")).toBeInTheDocument(); + // Settings on → every milestone row exposes its rename pencil. + // We assert the current row's pencil specifically (the others + // mirror it) so the test reads as "pencil for Chapter 1 is back". + expect(getRenameMilestoneButton()).toBeInTheDocument(); expect(screen.getByLabelText("Toggle Milestone Settings")).toHaveAttribute( "aria-pressed", "true" ); }); - it("reveals per-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. + it("reveals per-milestone-subdivision rename pencils when settings mode is open", async () => { + // Milestone subdivisions need a `key` for the rename pencil to + // render at all (per the canRename guard); supply one so we can + // verify the gear toggle uncovers them. renderMilestoneAccordion({ initialSettingsMode: false, getSubsectionsForMilestone: vi.fn(() => [ @@ -1410,13 +1441,13 @@ describe("MilestoneAccordion - Milestone Editing", () => { ]), }); - expect(screen.queryByLabelText("Rename Subsection")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Rename Milestone Subdivision")).not.toBeInTheDocument(); await act(async () => { fireEvent.click(screen.getByLabelText("Toggle Milestone Settings")); }); - const renamePencils = screen.getAllByLabelText("Rename Subsection"); + const renamePencils = screen.getAllByLabelText("Rename Milestone Subdivision"); expect(renamePencils.length).toBeGreaterThan(0); }); @@ -1445,7 +1476,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { await act(async () => { fireEvent.click(screen.getByLabelText("Toggle Milestone Settings")); }); - expect(screen.getByLabelText("Edit Milestone")).toBeInTheDocument(); + expect(getRenameMilestoneButton()).toBeInTheDocument(); // Close and reopen — the user should land back in read-only mode // so an accidental click on the gear isn't sticky across sessions. @@ -1496,7 +1527,10 @@ describe("MilestoneAccordion - Milestone Editing", () => { ); }); - expect(screen.queryByLabelText("Edit Milestone")).not.toBeInTheDocument(); + // After remount in read-only mode, NO milestone exposes a rename + // pencil — the gear must collapse settings rather than persist + // through an accordion close/reopen cycle. + expect(screen.queryAllByLabelText("Rename Milestone")).toHaveLength(0); expect(screen.getByLabelText("Toggle Milestone Settings")).toHaveAttribute( "aria-pressed", "false" @@ -1504,13 +1538,13 @@ describe("MilestoneAccordion - Milestone Editing", () => { }); }); - describe("Edit Mode - Accordion Close (no refresh on close)", () => { + describe("Milestone Rename - Accordion Close (no refresh on close)", () => { it("should not send refreshWebviewAfterMilestoneEdits when accordion closes after saving (provider pushes updates immediately on save)", async () => { const { rerender } = renderMilestoneAccordion(); - const editButton = screen.getByLabelText("Edit Milestone"); + const renameButton = getRenameMilestoneButton(); await act(async () => { - fireEvent.click(editButton); + fireEvent.click(renameButton); }); const input = screen.getByDisplayValue("Chapter 1") as HTMLInputElement; @@ -1518,7 +1552,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.change(input, { target: { value: "New Value" } }); }); - const saveButton = screen.getByLabelText("Save Milestone"); + const saveButton = screen.getByLabelText("Save Milestone Rename"); await act(async () => { fireEvent.click(saveButton); }); diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx index ad567b4fb..f1305cb60 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -927,9 +927,9 @@ export function MilestoneAccordion({ {isEditingMilestone ? ( <> @@ -1237,9 +1237,9 @@ export function MilestoneAccordion({ {isEditingThisRow ? ( <> handleSubsectionEditClick( e, From 77897273f68425c3ad6ea32b8fe79b2476a54c6e Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Thu, 7 May 2026 16:59:31 -0500 Subject: [PATCH 20/34] Extract milestone cell builder into shared milestoneCellUtils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls `buildMilestoneCellPayload` and the chapter/book-name helpers out of `migrationUtils.ts` so importers, migrations, and in-editor structural edits can share a single source of truth for milestone-cell shape (label format, edit-history, kind/languageId envelope, optional initial `metadata.data`). Pure refactor — no behavior change. --- src/projectManager/utils/migrationUtils.ts | 154 ++-------------- src/utils/milestoneCellUtils.ts | 193 +++++++++++++++++++++ 2 files changed, 207 insertions(+), 140 deletions(-) create mode 100644 src/utils/milestoneCellUtils.ts diff --git a/src/projectManager/utils/migrationUtils.ts b/src/projectManager/utils/migrationUtils.ts index ecbc059aa..278a06dfb 100644 --- a/src/projectManager/utils/migrationUtils.ts +++ b/src/projectManager/utils/migrationUtils.ts @@ -1,6 +1,5 @@ import * as vscode from "vscode"; import * as path from "path"; -import { randomUUID } from "crypto"; import * as dugiteGit from "../../utils/dugiteGit"; import { CodexContentSerializer } from "@/serializer"; import { vrefData } from "@/utils/verseRefUtils/verseData"; @@ -11,13 +10,16 @@ import { getAuthApi } from "../../extension"; import { extractParentCellIdFromParatext } from "../../providers/codexCellEditorProvider/utils/cellUtils"; import { generateCellIdFromHash, isUuidFormat } from "../../utils/uuidUtils"; import { getCorrespondingSourceUri, getCorrespondingCodexUri } from "../../utils/codexNotebookUtils"; +import { + buildMilestoneCellPayload, + extractChapterFromCellId, +} from "../../utils/milestoneCellUtils"; import { parseVerseRef, getSortKeyFromParsedRef, stripCellIdSuffix, type ParsedVerseRef, } from "../../utils/verseRefUtils"; -import bibleData from "../../../webviews/codex-webviews/src/assets/bible-books-lookup.json"; import { resolveCodexCustomMerge, mergeDuplicateCellsUsingResolverLogic } from "./merge/resolvers"; import { atomicWriteUriText } from "../../utils/notebookSafeSaveUtils"; import { normalizeNotebookFileText, formatJsonForNotebookFile } from "../../utils/notebookFileFormattingUtils"; @@ -2010,112 +2012,6 @@ async function getCurrentUserName(): Promise { return "unknown"; } -/** - * Extracts the chapter/section number from a cellId. - * Handles formats like: - * - "GEN 1:1" -> "1" - * - "Book Name 2:5" -> "2" - * - "filename 1:1" -> "1" - * Returns null if the pattern doesn't match. - */ -function extractChapterFromCellId(cellId: string): string | null { - if (!cellId) return null; - - // Pattern: anything followed by space, then number, colon, number - // e.g., "GEN 1:1", "Book Name 2:5", "filename 1:1" - const match = cellId.match(/\s+(\d+):(\d+)(?::|$)/); - if (match) { - return match[1]; // Return the chapter number (first number) - } - return null; -} - -/** - * Extracts chapter number from a cell using priority order: - * 1. metadata.chapterNumber (Biblica) - * 2. metadata.chapter (USFM) - * 3. metadata.data?.chapter (legacy) - * 4. extractChapterFromCellId (from cellId) - * 5. milestoneIndex (final fallback, 1-indexed) - */ -function extractChapterFromCell(cell: any, milestoneIndex: number): string { - // Priority 1: metadata.chapterNumber (Biblica) - if (cell?.metadata?.chapterNumber !== undefined && cell.metadata.chapterNumber !== null) { - return String(cell.metadata.chapterNumber); - } - - // Priority 2: metadata.chapter (USFM) - if (cell?.metadata?.chapter !== undefined && cell.metadata.chapter !== null) { - return String(cell.metadata.chapter); - } - - // Priority 3: metadata.data?.chapter (legacy) - if (cell?.metadata?.data?.chapter !== undefined && cell.metadata.data.chapter !== null) { - return String(cell.metadata.data.chapter); - } - - // Priority 4: Extract from cellId - const cellId = cell?.metadata?.id || cell?.id; - if (cellId) { - const chapterFromId = extractChapterFromCellId(cellId); - if (chapterFromId) { - return chapterFromId; - } - } - - // Priority 5: Use milestone index (1-indexed) - return milestoneIndex.toString(); -} - -/** - * Extracts book abbreviation from a cell's globalReferences or cellMarkers. - * Returns null if no book abbreviation can be found. - */ -function extractBookNameFromCell(cell: any): string | null { - // Priority 1: Extract from globalReferences array (preferred method) - const globalRefs = cell?.data?.globalReferences || cell?.metadata?.data?.globalReferences; - if (globalRefs && Array.isArray(globalRefs) && globalRefs.length > 0) { - const firstRef = globalRefs[0]; - // Extract book name: "GEN 1:1" -> "GEN" or "TheChosen-201-en-SingleSpeaker 1:jkflds" -> "TheChosen-201-en-SingleSpeaker" - const bookMatch = firstRef.match(/^([^\s]+)/); - if (bookMatch) { - return bookMatch[1]; - } - } - - // Priority 2: Fallback to cellMarkers (legacy support during migration) - if (cell?.cellMarkers?.[0]) { - const firstMarker = cell.cellMarkers[0].split(":")[0]; - if (firstMarker) { - const parts = firstMarker.split(" "); - return parts[0]; - } - } - - // Priority 3: Extract from cellId - const cellId = cell?.metadata?.id || cell?.id; - if (cellId) { - // Extract book name from cellId: "GEN 1:1" -> "GEN" - const bookMatch = cellId.match(/^([^\s]+)/); - if (bookMatch) { - return bookMatch[1]; - } - } - - return null; -} - -/** - * Gets the localized book name from a book abbreviation. - * Returns the abbreviation itself if no localized name is found. - */ -function getLocalizedBookName(bookAbbr: string): string { - if (!bookAbbr) return bookAbbr; - - const bookInfo = (bibleData as any[]).find((book) => book.abbr === bookAbbr); - return bookInfo?.name || bookAbbr; -} - /** * Extracts chapter number from a milestone value (e.g. "John 4", "4", "GEN 2"). * Used for verse-range migration to associate milestones with content chapters. @@ -2133,44 +2029,22 @@ function extractChapterNumberFromMilestoneValue(value: string | undefined): numb /** * Creates a milestone cell with book name and chapter number derived from the cell below it. - * Format: "BookName ChapterNumber" (e.g., "Isaiah 1") + * Format: "BookName ChapterNumber" (e.g., "Isaiah 1"). Thin wrapper around the + * shared `buildMilestoneCellPayload` helper that resolves the current user's + * author name through the auth API; the in-editor structural-edit handlers + * call the shared helper directly with `document._author`. * @param cell - The cell to derive chapter information from * @param milestoneIndex - The index of the milestone (1-indexed) * @param uuid - Optional UUID to use for the milestone cell. If not provided, generates a new one. */ async function createMilestoneCell(cell: any, milestoneIndex: number, uuid?: string): Promise { - const cellUuid = uuid || randomUUID(); - const chapterNumber = extractChapterFromCell(cell, milestoneIndex); - const currentTimestamp = Date.now(); const author = await getCurrentUserName(); - - // Extract book name from cell - const bookAbbr = extractBookNameFromCell(cell); - const bookName = bookAbbr ? getLocalizedBookName(bookAbbr) : null; - - // Combine book name and chapter number, or use just chapter number if no book name found - const milestoneValue = bookName ? `${bookName} ${chapterNumber}` : chapterNumber; - - // Create initial edit entry similar to source file cells - const initialEdit = { - editMap: EditMapUtils.value(), - value: milestoneValue, - timestamp: currentTimestamp - 1000, // Ensure it's before any user edits - type: EditType.INITIAL_IMPORT, - author: author, - validatedBy: [] - }; - - return { - kind: 2, // vscode.NotebookCellKind.Code - languageId: "html", - value: milestoneValue, - metadata: { - id: cellUuid, - type: CodexCellTypes.MILESTONE, - edits: [initialEdit] - } - }; + return buildMilestoneCellPayload({ + referenceCell: cell, + milestoneOrdinal: milestoneIndex, + author, + uuid, + }); } diff --git a/src/utils/milestoneCellUtils.ts b/src/utils/milestoneCellUtils.ts new file mode 100644 index 000000000..1c511f519 --- /dev/null +++ b/src/utils/milestoneCellUtils.ts @@ -0,0 +1,193 @@ +import { randomUUID } from "crypto"; +import { CodexCellTypes, EditType } from "../../types/enums"; +import { EditMapUtils } from "./editMapUtils"; +import bibleData from "../../webviews/codex-webviews/src/assets/bible-books-lookup.json"; + +/** + * Extracts the chapter number from a structured cellId of the form + * "BOOK CHAPTER:VERSE" (e.g. `"GEN 1:1"` → `"1"`). Returns null when the + * pattern does not match — including for UUID-shaped cell IDs. + */ +export function extractChapterFromCellId(cellId: string): string | null { + if (!cellId) return null; + // Pattern: :(:|end) + // Captures the first digit run as the chapter; tolerates a trailing + // verse-suffix (verse-range disambiguation). + const match = cellId.match(/\s+(\d+):(\d+)(?::|$)/); + if (match) { + return match[1]; + } + return null; +} + +/** + * Resolves a chapter label for a milestone cell using a cascade of metadata + * locations, falling back to the milestone ordinal as the last resort. The + * priority is intentional: explicit Biblica/USFM chapter metadata wins over + * legacy `data.chapter`, which wins over a parsed `cellId`. + */ +export function extractChapterFromCell(cell: any, milestoneOrdinal: number): string { + if (cell?.metadata?.chapterNumber !== undefined && cell.metadata.chapterNumber !== null) { + return String(cell.metadata.chapterNumber); + } + if (cell?.metadata?.chapter !== undefined && cell.metadata.chapter !== null) { + return String(cell.metadata.chapter); + } + if (cell?.metadata?.data?.chapter !== undefined && cell.metadata.data.chapter !== null) { + return String(cell.metadata.data.chapter); + } + const cellId = cell?.metadata?.id || cell?.id; + if (cellId) { + const chapterFromId = extractChapterFromCellId(cellId); + if (chapterFromId) { + return chapterFromId; + } + } + return milestoneOrdinal.toString(); +} + +/** + * Pulls a book abbreviation out of `globalReferences`, then `cellMarkers`, + * then a parsed `cellId`. Returns null when no abbreviation can be found — + * e.g. when the cell is identified by a UUID rather than a scripture-shaped + * id. + */ +export function extractBookNameFromCell(cell: any): string | null { + const globalRefs = cell?.data?.globalReferences || cell?.metadata?.data?.globalReferences; + if (globalRefs && Array.isArray(globalRefs) && globalRefs.length > 0) { + const firstRef = globalRefs[0]; + const bookMatch = firstRef.match(/^([^\s]+)/); + if (bookMatch) { + return bookMatch[1]; + } + } + if (cell?.cellMarkers?.[0]) { + const firstMarker = cell.cellMarkers[0].split(":")[0]; + if (firstMarker) { + const parts = firstMarker.split(" "); + return parts[0]; + } + } + const cellId = cell?.metadata?.id || cell?.id; + if (cellId) { + const bookMatch = cellId.match(/^([^\s]+)/); + if (bookMatch) { + return bookMatch[1]; + } + } + return null; +} + +/** + * Translates a USFM/Biblica book abbreviation to its localized display name + * (e.g. `"GEN"` → `"Genesis"`). Falls through to the abbreviation when no + * mapping is found so the caller never gets back an empty string. + */ +export function getLocalizedBookName(bookAbbr: string): string { + if (!bookAbbr) return bookAbbr; + const bookInfo = (bibleData as any[]).find((book) => book.abbr === bookAbbr); + return bookInfo?.name || bookAbbr; +} + +/** + * Computes the human-readable label for a milestone cell. Prefers + * `"BookName ChapterNumber"` (e.g. `"Isaiah 1"`); falls back to the bare + * chapter number for non-Bible content. + */ +export function buildMilestoneLabelFromCell(cell: any, milestoneOrdinal: number): string { + const chapterNumber = extractChapterFromCell(cell, milestoneOrdinal); + const bookAbbr = extractBookNameFromCell(cell); + const bookName = bookAbbr ? getLocalizedBookName(bookAbbr) : null; + return bookName ? `${bookName} ${chapterNumber}` : chapterNumber; +} + +export interface MilestoneCellPayloadOptions { + /** + * Cell to derive book/chapter context from. The payload's display value + * comes from this cell's metadata (chapter number, book abbreviation). + */ + referenceCell: any; + /** + * 1-indexed milestone ordinal. Used as the final fallback when no + * structured chapter metadata is available. + */ + milestoneOrdinal: number; + /** + * Author name recorded against the initial edit entry. Callers pass + * the current user; `migrationUtils` resolves this through `getAuthApi`, + * the in-editor handler resolves it through `document.refreshAuthor`. + */ + author: string; + /** Stable UUID for the milestone cell. Generated on demand if omitted. */ + uuid?: string; + /** + * Optional override for the milestone label. When omitted we derive it + * from `referenceCell` via `buildMilestoneLabelFromCell`. Callers that + * already have a custom label (e.g. promoting a named subdivision) can + * pass it directly. + */ + valueOverride?: string; + /** + * Optional `metadata.data` blob to attach to the milestone cell at + * creation time. Used by structural edits to persist subdivisions and + * subdivision-name overrides on a brand-new milestone in one shot, + * skipping a follow-up `updateCellData` call. + */ + initialData?: Record; +} + +/** + * Builds the on-disk shape of a milestone notebook cell. Centralising this + * keeps importers, migrations, and in-editor structural edits aligned on + * label format, edit-history shape, and the surrounding kind/languageId + * envelope. + * + * The returned object matches `CustomNotebookCellData` shape for milestone + * cells: `kind: 2` (NotebookCellKind.Code), `languageId: "html"`, an + * INITIAL_IMPORT-style edit entry timestamped slightly in the past so it + * sorts before any subsequent USER_EDIT. + */ +export function buildMilestoneCellPayload(opts: MilestoneCellPayloadOptions): any { + const { + referenceCell, + milestoneOrdinal, + author, + uuid, + valueOverride, + initialData, + } = opts; + const cellUuid = uuid || randomUUID(); + const milestoneValue = + valueOverride && valueOverride.length > 0 + ? valueOverride + : buildMilestoneLabelFromCell(referenceCell, milestoneOrdinal); + const currentTimestamp = Date.now(); + // Initial-import edit anchors the milestone label in the merge log so the + // value survives 3-way merges even when the cell has not yet been touched + // by a USER_EDIT. Stamping the timestamp slightly in the past keeps it + // ordered before any subsequent user edit recorded in the same tick. + const initialEdit = { + editMap: EditMapUtils.value(), + value: milestoneValue, + timestamp: currentTimestamp - 1000, + type: EditType.INITIAL_IMPORT, + author, + validatedBy: [], + }; + + const metadata: any = { + id: cellUuid, + type: CodexCellTypes.MILESTONE, + edits: [initialEdit], + }; + if (initialData && Object.keys(initialData).length > 0) { + metadata.data = { ...initialData }; + } + + return { + kind: 2, // vscode.NotebookCellKind.Code + languageId: "html", + value: milestoneValue, + metadata, + }; +} From 2b4d3710540499b20c1ea1d71ea407da850a560c Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Thu, 7 May 2026 16:59:46 -0500 Subject: [PATCH 21/34] Add subdivision split/merge helpers and milestone-cell insertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three additive primitives the new milestone-placement edits need: - `splitPlacementsAtAnchor` partitions a milestone's `MilestoneSubdivisionPlacement[]` at a boundary cell, lifting any pre-existing subdivision *at* the boundary onto the new milestone as its implicit first-subdivision name (so "Section B" becomes the promoted milestone's label and stops doubling as a subdivision). - `mergePlacementsForRemovedMilestone` merges two milestones' placement lists when one is removed or demoted; the demote path preserves the boundary as a new placement carrying the demoted milestone's label. - `CodexDocument.insertMilestoneCell` adds a top-level milestone cell at a chosen anchor with no `parentId`, using `buildMilestoneCellPayload` for shape parity with importers/migrations. Backed by a `force` flag on `updateCellMilestoneIndices` so structural edits that don't change the cell count still reflush the SQLite milestone-index FTS. No callers yet — wired up in the next commit. --- .../codexCellEditorProvider/codexDocument.ts | 104 +++++++++- .../utils/subdivisionUtils.ts | 182 ++++++++++++++++++ 2 files changed, 283 insertions(+), 3 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexDocument.ts b/src/providers/codexCellEditorProvider/codexDocument.ts index 298c20051..cb36a4c6a 100644 --- a/src/providers/codexCellEditorProvider/codexDocument.ts +++ b/src/providers/codexCellEditorProvider/codexDocument.ts @@ -28,6 +28,7 @@ import { getSQLiteIndexManager, isDBShuttingDown } from "../../activationHelpers import { getCellValueData, cellHasAudioUsingAttachments, computeValidationStats, computeProgressPercents, shouldExcludeCellFromProgress, shouldExcludeQuillCellFromProgress, countActiveValidations, hasTextContent } from "../../../sharedUtils"; import { extractParentCellIdFromParatext, convertCellToQuillContent } from "./utils/cellUtils"; import { FIRST_SUBDIVISION_KEY, findSubdivisionIndexForRoot, resolveSubdivisions } from "./utils/subdivisionUtils"; +import { buildMilestoneCellPayload } from "../../utils/milestoneCellUtils"; import { formatJsonForNotebookFile, normalizeNotebookFileText } from "../../utils/notebookFileFormattingUtils"; import { serializeNotebookWithCellCache } from "./utils/cachedNotebookSerializer"; import { atomicWriteUriText, readExistingFileOrThrow } from "../../utils/notebookSafeSaveUtils"; @@ -1300,6 +1301,95 @@ export class CodexCellDocument implements vscode.CustomDocument { }); } + /** + * Inserts a milestone cell at the position currently occupied by + * `referenceCellId`, pushing that root content cell (and everything after + * it) into the new milestone's range. Distinct from `addCell` because + * milestone cells are top-level structural rows: they MUST NOT carry a + * `parentId` (which would erroneously make them children of the reference + * cell), they always carry an INITIAL_IMPORT-style edit so the label is + * durable across 3-way merges, and they may be born with `metadata.data` + * already populated (subdivisions, name overrides) so a structural edit + * lands atomically rather than as a sequence of follow-up updates. + * + * Returns the inserted cell so callers can stash its UUID for paired + * source↔target mirroring. + */ + public insertMilestoneCell(opts: { + newCellId?: string; + referenceCellId: string; + valueOverride?: string; + initialData?: Record; + }): { cellId: string; cellIndex: number; } { + const { newCellId, referenceCellId, valueOverride, initialData } = opts; + const indexOfReferenceCell = this._documentData.cells.findIndex( + (cell) => cell.metadata?.id === referenceCellId + ); + if (indexOfReferenceCell === -1) { + throw new Error( + `Could not find reference cell ${referenceCellId} for milestone insertion` + ); + } + + // Use the reference cell's metadata to derive a sensible default + // label ("BookName ChapterNumber"). Caller can override via + // `valueOverride` — typical when promoting a named subdivision. + const referenceCell = this._documentData.cells[indexOfReferenceCell]; + // Milestone ordinals here are purely for the fallback chapter label + // when no structured chapter metadata is available; we count existing + // (non-deleted) milestone cells before the insertion point + 1. + let ordinal = 1; + for (let i = 0; i < indexOfReferenceCell; i++) { + const c = this._documentData.cells[i]; + if ( + c.metadata?.type === CodexCellTypes.MILESTONE && + c.metadata?.data?.deleted !== true + ) { + ordinal++; + } + } + + const payload = buildMilestoneCellPayload({ + referenceCell, + milestoneOrdinal: ordinal, + author: this._author, + uuid: newCellId, + valueOverride, + initialData, + }); + + // Splice in BEFORE the reference cell so it becomes the new + // milestone's first content cell. + this._documentData.cells.splice(indexOfReferenceCell, 0, payload as CustomNotebookCellData); + + // Cell-list shape changed → milestone partition is stale. + this.invalidateMilestoneIndexCache(); + + const insertedId = payload.metadata.id; + this._edits.push({ + type: "addCell", + newCellId: insertedId, + referenceCellId, + cellType: CodexCellTypes.MILESTONE, + data: payload.metadata.data, + }); + + this._isDirty = true; + this._dirtyCellIds.add(insertedId); + this._onDidChangeForVsCodeAndWebview.fire({ + edits: [ + { + newCellId: insertedId, + referenceCellId, + cellType: CodexCellTypes.MILESTONE, + data: payload.metadata.data, + }, + ], + }); + + return { cellId: insertedId, cellIndex: indexOfReferenceCell }; + } + // Method to update notebook metadata public updateNotebookMetadata(newMetadata: Partial) { if (!this._documentData.metadata) { @@ -1726,8 +1816,13 @@ export class CodexCellDocument implements vscode.CustomDocument { /** * Updates the database with milestone indices for all cells. * This should be called after buildMilestoneIndex() to persist the milestone indices. + * + * Pass `force: true` for structural edits (insert/soft-delete of a + * milestone cell) where the cell COUNT may not change but the per-cell + * `milestoneIndex` assignment does. The default fast path keys on cell + * count alone and would otherwise skip the reflush. */ - public async updateCellMilestoneIndices(): Promise { + public async updateCellMilestoneIndices(options?: { force?: boolean; }): Promise { if (!this.refreshIndexManager()) { console.warn(`[CodexDocument] Index manager not available for milestone index update`); return; @@ -1736,9 +1831,12 @@ export class CodexCellDocument implements vscode.CustomDocument { const cells = this._documentData.cells || []; const currentCellCount = cells.length; - // Optimization: Skip update if milestone indices haven't changed - // If cache is valid and cell count matches last update, indices haven't changed + // Optimization: Skip update if milestone indices haven't changed. + // Structural edits that mutate `milestoneIndex` without changing the + // cell COUNT (notably soft-deleting a milestone cell) must opt out via + // `force` so the SQLite mirror doesn't drift from the in-memory state. if ( + !options?.force && this._cachedMilestoneIndex !== null && this._cachedMilestoneIndexCellCount === currentCellCount && this._lastUpdatedMilestoneIndexCellCount === currentCellCount diff --git a/src/providers/codexCellEditorProvider/utils/subdivisionUtils.ts b/src/providers/codexCellEditorProvider/utils/subdivisionUtils.ts index b596c1f76..a9b154cea 100644 --- a/src/providers/codexCellEditorProvider/utils/subdivisionUtils.ts +++ b/src/providers/codexCellEditorProvider/utils/subdivisionUtils.ts @@ -280,3 +280,185 @@ export function findSubdivisionIndexForRoot( } return -1; } + +/** + * Result of `splitPlacementsAtAnchor`. Used by the milestone-placement edit + * pipeline (add / promote) to atomically re-partition an existing milestone's + * subdivisions when a new milestone boundary is introduced inside it. + */ +export interface SplitPlacementsResult { + /** + * Placements that fall strictly before the new boundary. These remain on + * the original (now-shorter) milestone. + */ + before: MilestoneSubdivisionPlacement[]; + /** + * Placements that fall strictly after the new boundary, re-anchored as + * subdivisions of the freshly-created milestone. The placement at the + * boundary itself (if any) is NOT included here — it becomes the new + * milestone's implicit first subdivision and its name (if any) is + * surfaced via `boundaryName`. + */ + after: MilestoneSubdivisionPlacement[]; + /** + * If a placement existed exactly at the new boundary cell, its `name` is + * returned here. Callers persist this as the new milestone's + * `subdivisionNames["__start__"]` so the implicit first subdivision keeps + * the user's label even though it no longer corresponds to a placement. + */ + boundaryName?: string; +} + +/** + * Partitions a milestone's existing placements at an anchor cell when a new + * milestone is being inserted there (whether by direct add or by promotion of + * an existing subdivision break). + * + * `rootIds` is the ordered list of root content cell IDs in the original + * (un-split) milestone. `boundaryCellId` must appear in `rootIds`; otherwise + * the function returns the input unchanged in `before` (defensive). + * + * Placements whose `startCellId` is not a root cell are silently dropped — the + * resolver would prune them anyway. + */ +export function splitPlacementsAtAnchor( + placements: MilestoneSubdivisionPlacement[] | undefined, + rootIds: string[], + boundaryCellId: string +): SplitPlacementsResult { + const empty: SplitPlacementsResult = { before: [], after: [] }; + if (!boundaryCellId) return empty; + const boundaryIndex = rootIds.indexOf(boundaryCellId); + // Boundary outside the milestone or at the very start — caller should have + // rejected this earlier; we degrade gracefully to "no split" so we never + // silently lose data. + if (boundaryIndex <= 0) { + return { + before: Array.isArray(placements) ? [...placements] : [], + after: [], + }; + } + + const before: MilestoneSubdivisionPlacement[] = []; + const after: MilestoneSubdivisionPlacement[] = []; + let boundaryName: string | undefined; + const seen = new Set(); + + for (const placement of placements ?? []) { + if (!placement || typeof placement.startCellId !== "string") continue; + if (seen.has(placement.startCellId)) continue; + seen.add(placement.startCellId); + const idx = rootIds.indexOf(placement.startCellId); + if (idx === -1) continue; // stale anchor — drop + if (idx < boundaryIndex) { + const entry: MilestoneSubdivisionPlacement = { + startCellId: placement.startCellId, + }; + if (typeof placement.name === "string" && placement.name.length > 0) { + entry.name = placement.name; + } + before.push(entry); + } else if (idx === boundaryIndex) { + // Placement coincides with the new milestone boundary. We don't + // carry it into `after` because the new milestone's first + // subdivision is implicit; instead we surface its name so callers + // can stash it in `subdivisionNames[FIRST_SUBDIVISION_KEY]`. + if (typeof placement.name === "string" && placement.name.length > 0) { + boundaryName = placement.name; + } + } else { + const entry: MilestoneSubdivisionPlacement = { + startCellId: placement.startCellId, + }; + if (typeof placement.name === "string" && placement.name.length > 0) { + entry.name = placement.name; + } + after.push(entry); + } + } + + return { before, after, boundaryName }; +} + +/** + * Result of `mergePlacementsForRemovedMilestone`. Captures both the new + * placement list to write onto the surviving (previous) milestone, and the + * recommended source-side first-subdivision name carried over from the + * removed milestone (relevant only when `boundaryAnchorCellId` matches the + * surviving milestone's first root cell — see merge logic below). + */ +export interface MergePlacementsResult { + placements: MilestoneSubdivisionPlacement[]; +} + +/** + * Builds the merged placement list for the surviving (previous) milestone + * when a milestone is removed or demoted. Both operations expand the + * previous milestone's range to absorb the removed milestone's content + * cells; the difference is whether the boundary itself is preserved as a + * custom subdivision break. + * + * - `prevPlacements`: existing placements on the surviving milestone. + * - `removedPlacements`: placements on the milestone being removed (these + * are lifted up because their anchors still point at valid root cells in + * the merged milestone). + * - `boundaryAnchorCellId`: the first root content cell of the removed + * milestone. After the merge it sits at the seam between the two + * milestones' original cell ranges. + * - `boundaryName`: optional label for the boundary placement. When + * `preserveBoundary` is `true` and `boundaryName` is set, the boundary + * becomes a new placement on the surviving milestone (carrying that + * name) so the section heading isn't silently lost. + * - `preserveBoundary`: `true` for **demote** semantics (boundary kept as + * a subdivision break), `false` for **remove** semantics (boundary gone + * entirely). + * + * Placements are deduplicated by `startCellId` (last write wins on name). + */ +export function mergePlacementsForRemovedMilestone({ + prevPlacements, + removedPlacements, + boundaryAnchorCellId, + boundaryName, + preserveBoundary, +}: { + prevPlacements: MilestoneSubdivisionPlacement[] | undefined; + removedPlacements: MilestoneSubdivisionPlacement[] | undefined; + boundaryAnchorCellId?: string; + boundaryName?: string; + preserveBoundary: boolean; +}): MergePlacementsResult { + const merged = new Map(); + const push = (p: MilestoneSubdivisionPlacement | undefined) => { + if (!p || typeof p.startCellId !== "string") return; + const entry: MilestoneSubdivisionPlacement = { startCellId: p.startCellId }; + if (typeof p.name === "string" && p.name.length > 0) { + entry.name = p.name; + } + merged.set(p.startCellId, entry); + }; + + for (const p of prevPlacements ?? []) push(p); + + if ( + preserveBoundary && + typeof boundaryAnchorCellId === "string" && + boundaryAnchorCellId.length > 0 + ) { + // For demote: stamp the boundary as a fresh placement carrying the + // removed milestone's label as its name (when provided). Setting it + // before the removed milestone's other placements means the explicit + // boundary entry will be replaced if the removed milestone happened + // to also have a placement at that exact cell ID — that's fine; the + // removed-side name takes precedence as the more specific override. + const entry: MilestoneSubdivisionPlacement = { startCellId: boundaryAnchorCellId }; + if (typeof boundaryName === "string" && boundaryName.length > 0) { + entry.name = boundaryName; + } + merged.set(boundaryAnchorCellId, entry); + } + + for (const p of removedPlacements ?? []) push(p); + + return { placements: Array.from(merged.values()) }; +} From a335d9d6c583c4bed1309fe2ae845cfc97d65942 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Thu, 7 May 2026 17:00:07 -0500 Subject: [PATCH 22/34] Add milestone placement editing on source files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets translators add, remove, promote (subdivision → milestone), and demote (milestone → subdivision break) milestones on the source side. Mirrors immediately to the paired target file when the milestone roots agree, preserving UUIDs and anchor cell IDs. Gated end-to-end by a new workspace setting `enableMilestonePlacementEditing` (default off) and surfaced in Interface Settings. Wiring: - `package.json`: declare the new boolean setting. - `interfaceSettings.ts` + `InterfaceSettings/index.tsx`: surface the toggle in the Pagination & Subdivisions section and propagate live changes back to the editor webview. - `codexCellEditorProvider.ts`: read the setting, plumb it into both initial-content payloads, and broadcast preference updates. - `codexCellEditorMessagehandling.ts`: four new handlers (`addMilestoneAtCell`, `removeMilestone`, `promoteSubdivisionToMilestone`, `demoteMilestoneToSubdivision`) built on a pair of helpers — `commitSplitMilestoneAtAnchor` and `commitMergeMilestoneIntoPrevious` — that handle source validation, subdivision redistribution, source/target mirror with root-divergence guards, milestone-index reflush (`force: true`), and webview refresh. - `MilestoneAccordion.tsx`: source-only, settings-mode-only controls for add/remove/promote/demote, with a two-click confirmation pattern on the destructive demote/remove paths. - `types/index.d.ts`: new `EditorPostMessages` commands, the `enableMilestonePlacementEditing` field on `providerSendsInitialContentPaginated`, and a new `updateMilestonePlacementEditingPreference` push. --- package.json | 6 + src/interfaceSettings/interfaceSettings.ts | 20 +- .../codexCellEditorMessagehandling.ts | 792 +++++++++++++++++- .../codexCellEditorProvider.ts | 30 + types/index.d.ts | 84 ++ .../ChapterNavigationHeader.tsx | 8 + .../src/CodexCellEditor/CodexCellEditor.tsx | 19 + .../components/MilestoneAccordion.tsx | 454 +++++++++- .../src/InterfaceSettings/index.tsx | 37 + 9 files changed, 1435 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 098fb560d..5baaa6422 100644 --- a/package.json +++ b/package.json @@ -962,6 +962,12 @@ "maximum": 5000, "description": "Maximum number of cells the editor will leave inside a single subdivision. Stretches between user-defined breaks that exceed this length are sub-chunked using 'Cells Per Page'. Set to 0 to disable (in which case 'Cells Per Page' itself acts as the threshold). Useful when you want a higher 'Cells Per Page' default but still allow short logical pages between custom breaks to remain unbroken." }, + "codex-editor-extension.enableMilestonePlacementEditing": { + "title": "Enable Milestone Placement Editing", + "type": "boolean", + "default": false, + "description": "When enabled, the milestone accordion's settings mode reveals controls for editing where milestones sit in the source document — adding, removing, promoting a subdivision break to a milestone, or demoting a milestone to a subdivision break. Edits made on the source mirror to the paired target by UUID." + }, "codex-editor-extension.useOnlyValidatedExamples": { "title": "Use Only Validated Examples", "type": "boolean", diff --git a/src/interfaceSettings/interfaceSettings.ts b/src/interfaceSettings/interfaceSettings.ts index 9c32e8720..9ffbde4dc 100644 --- a/src/interfaceSettings/interfaceSettings.ts +++ b/src/interfaceSettings/interfaceSettings.ts @@ -68,6 +68,10 @@ export async function openInterfaceSettings() { false ); const maxSubdivisionLength = config.get("maxSubdivisionLength", 0); + const enableMilestonePlacementEditing = config.get( + "enableMilestonePlacementEditing", + false + ); panel.webview.postMessage({ command: "init", @@ -76,6 +80,7 @@ export async function openInterfaceSettings() { cellsPerPage, useSubdivisionNumberLabels, maxSubdivisionLength, + enableMilestonePlacementEditing, }, }); }; @@ -151,6 +156,16 @@ export async function openInterfaceSettings() { ); break; } + + case "updateEnableMilestonePlacementEditing": { + const config = vscode.workspace.getConfiguration("codex-editor-extension"); + await config.update( + "enableMilestonePlacementEditing", + Boolean(message.value), + vscode.ConfigurationTarget.Workspace + ); + break; + } } }); @@ -161,7 +176,10 @@ export async function openInterfaceSettings() { e.affectsConfiguration("codex-editor-extension.highlightSearchResults") || e.affectsConfiguration("codex-editor-extension.cellsPerPage") || e.affectsConfiguration("codex-editor-extension.useSubdivisionNumberLabels") || - e.affectsConfiguration("codex-editor-extension.maxSubdivisionLength") + e.affectsConfiguration("codex-editor-extension.maxSubdivisionLength") || + e.affectsConfiguration( + "codex-editor-extension.enableMilestonePlacementEditing" + ) ) { sendInit(); } diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 2cbe0ea76..6b35e0d5b 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -22,7 +22,11 @@ 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 { + FIRST_SUBDIVISION_KEY, + mergePlacementsForRemovedMilestone, + splitPlacementsAtAnchor, +} from "./utils/subdivisionUtils"; import type { MilestoneSubdivisionPlacement } from "../../../types"; // Enable debug logging if needed @@ -170,6 +174,10 @@ export async function sendMilestoneRefreshToWebview( "useSubdivisionNumberLabels", false ); + const enableMilestonePlacementEditing = config.get( + "enableMilestonePlacementEditing", + false + ); safePostMessageToPanel(webviewPanel, { type: "providerSendsInitialContentPaginated", rev, @@ -183,6 +191,7 @@ export async function sendMilestoneRefreshToWebview( validationCount: validationCount, validationCountAudio: validationCountAudio, useSubdivisionNumberLabels, + enableMilestonePlacementEditing, }); safePostMessageToPanel(webviewPanel, { @@ -414,6 +423,649 @@ async function commitMilestoneSubdivisionPlacements({ } } +/** + * Reads the existing placements & subdivisionNames blob off a milestone cell. + * Tolerant of missing fields so callers can use it on freshly-created + * milestones (returns empty maps/arrays). + */ +function readMilestoneSubdivisionData(milestoneCell: any): { + placements: MilestoneSubdivisionPlacement[]; + subdivisionNames: { [k: string]: string; }; + subdivisionNamesFromSource: { [k: string]: string; }; +} { + const data = milestoneCell?.metadata?.data as + | { + subdivisions?: MilestoneSubdivisionPlacement[]; + subdivisionNames?: { [k: string]: string; }; + subdivisionNamesFromSource?: { [k: string]: string; }; + } + | undefined; + return { + placements: Array.isArray(data?.subdivisions) ? [...(data!.subdivisions!)] : [], + subdivisionNames: { ...(data?.subdivisionNames ?? {}) }, + subdivisionNamesFromSource: { ...(data?.subdivisionNamesFromSource ?? {}) }, + }; +} + +/** + * Partitions a `subdivisionNames` map across a milestone split. + * + * `keepKeys` is the set of keys that remain on the original (now-shorter) + * milestone — typically `FIRST_SUBDIVISION_KEY` plus the `startCellId` of + * every placement still in the "before" partition. `moveKeys` is the set of + * keys whose entries should travel to the new milestone, optionally remapped + * (e.g. the boundary anchor key becomes `FIRST_SUBDIVISION_KEY` on the new + * milestone since it's that milestone's implicit first subdivision now). + */ +function partitionSubdivisionNames( + nameMap: { [k: string]: string; }, + keepKeys: Set, + moveKeys: Map +): { kept: { [k: string]: string; }; moved: { [k: string]: string; }; } { + const kept: { [k: string]: string; } = {}; + const moved: { [k: string]: string; } = {}; + for (const [key, value] of Object.entries(nameMap)) { + if (typeof value !== "string" || value.length === 0) continue; + if (keepKeys.has(key)) { + kept[key] = value; + } else if (moveKeys.has(key)) { + const remappedKey = moveKeys.get(key)!; + moved[remappedKey] = value; + } + // Keys outside both sets are dropped (they reference cells that no + // longer correspond to any placement on either milestone — typically + // stale entries that the resolver was already pruning at render time). + } + return { kept, moved }; +} + +/** + * Validates that the source and target documents agree on which root content + * cells belong to a given milestone index. Used as a pre-flight before any + * structural milestone edit so we never insert / soft-delete a target cell + * by UUID when the documents have already drifted apart structurally. + */ +function sourceAndTargetMilestoneRootsMatch( + sourceDocument: CodexCellDocument, + targetDocument: CodexCellDocument, + milestoneIndex: number +): boolean { + const sourceRootIds = sourceDocument.getRootContentCellIdsForMilestone(milestoneIndex); + const targetRootIds = targetDocument.getRootContentCellIdsForMilestone(milestoneIndex); + if (sourceRootIds.length !== targetRootIds.length) return false; + for (let i = 0; i < sourceRootIds.length; i++) { + if (sourceRootIds[i] !== targetRootIds[i]) return false; + } + return true; +} + +/** + * Handles the source-side soft-delete + previous-milestone redistribution + * shared by `removeMilestone` and `demoteMilestoneToSubdivision`. The two + * commands differ only in `preserveBoundary`: demote keeps a custom + * subdivision break at the seam (carrying the deleted milestone's label as + * its name), remove drops the boundary entirely. + * + * Always mirrors to the paired target by UUID. When the target's root cell + * IDs for the affected milestones diverge from source we skip the structural + * mirror so we never delete a target milestone whose content has drifted — + * the callers surface a console.warn so the divergence is visible without + * popping a dialog at every save. + */ +async function commitMergeMilestoneIntoPrevious({ + document, + webviewPanel, + provider, + milestoneIndex, + preserveBoundary, + logPrefix, + errorPrefix, +}: { + document: CodexCellDocument; + webviewPanel: vscode.WebviewPanel; + provider: CodexCellEditorProvider; + milestoneIndex: number; + preserveBoundary: boolean; + logPrefix: string; + errorPrefix: string; +}): Promise { + if (!isSourceFileFlexible(document.uri)) { + console.warn( + `${logPrefix} Rejected write from non-source document:`, + document.uri.toString() + ); + vscode.window.showWarningMessage( + "Milestone placements can only be edited from the source file." + ); + return; + } + + if (milestoneIndex <= 0) { + console.warn( + `${logPrefix} Cannot remove the first milestone (or virtual milestone) at index 0` + ); + vscode.window.showWarningMessage( + "The first milestone cannot be removed." + ); + return; + } + + const sourceMilestoneIndex = document.buildMilestoneIndex(); + const removed = sourceMilestoneIndex.milestones[milestoneIndex]; + const previous = sourceMilestoneIndex.milestones[milestoneIndex - 1]; + if (!removed || !previous) { + console.error(`${logPrefix} Milestone neighbours not found`, { + milestoneIndex, + }); + vscode.window.showErrorMessage( + `${errorPrefix}: milestone neighbours not found at index ${milestoneIndex}` + ); + return; + } + + const removedCell = document.getCellByIndex(removed.cellIndex); + const previousCell = document.getCellByIndex(previous.cellIndex); + if ( + !removedCell || + removedCell.metadata?.type !== CodexCellTypes.MILESTONE || + !removedCell.metadata?.id || + !previousCell || + previousCell.metadata?.type !== CodexCellTypes.MILESTONE || + !previousCell.metadata?.id + ) { + console.error(`${logPrefix} Invalid milestone cells`, { + removed: removed.cellIndex, + previous: previous.cellIndex, + }); + vscode.window.showErrorMessage(`${errorPrefix}: invalid milestone cells.`); + return; + } + + const removedMilestoneCellId = removedCell.metadata.id; + const removedRootIds = document.getRootContentCellIdsForMilestone(milestoneIndex); + const boundaryAnchorCellId = removedRootIds[0]; + const removedLabel = (removedCell.value as string | undefined) ?? ""; + const removedData = readMilestoneSubdivisionData(removedCell); + const previousData = readMilestoneSubdivisionData(previousCell); + + const merged = mergePlacementsForRemovedMilestone({ + prevPlacements: previousData.placements, + removedPlacements: removedData.placements, + boundaryAnchorCellId, + boundaryName: removedLabel.length > 0 ? removedLabel : undefined, + preserveBoundary, + }); + + // Combine subdivisionNames maps. Removed milestone's __start__ entry maps + // onto the boundary cell ID on the surviving milestone (so its label + // travels with the cells). All other named entries keep their cell-ID + // keys verbatim — they still resolve to the same root cells, just inside + // a wider milestone range now. + const mergedSourceNames: { [k: string]: string; } = { ...previousData.subdivisionNames }; + for (const [key, value] of Object.entries(removedData.subdivisionNames)) { + if (typeof value !== "string" || value.length === 0) continue; + if (key === FIRST_SUBDIVISION_KEY) { + if (preserveBoundary && boundaryAnchorCellId) { + // Demote: the removed milestone's __start__ name becomes the + // boundary placement's name (overriding any inline name we + // already stamped from `removedLabel`, since explicit + // subdivision names are more specific than the milestone + // label). For pure remove, drop it (no boundary placement + // exists to attach the name to). + mergedSourceNames[boundaryAnchorCellId] = value; + } + continue; + } + mergedSourceNames[key] = value; + } + + const cancellationToken = new vscode.CancellationTokenSource().token; + + try { + await document.refreshAuthor(); + document.softDeleteCell(removedMilestoneCellId); + document.updateCellData(previousCell.metadata.id, { + subdivisions: merged.placements, + subdivisionNames: mergedSourceNames, + }); + await provider.saveCustomDocument(document, cancellationToken); + // Reflush per-cell milestoneIndex on the source. Cell count is + // unchanged after a soft-delete, so the optimistic short-circuit in + // updateCellMilestoneIndices would otherwise skip this update. + await document.updateCellMilestoneIndices({ force: true }); + } catch (error) { + console.error(`${logPrefix} Failed to update source:`, error); + vscode.window.showErrorMessage( + `${errorPrefix}: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + + let mirroredTargetDocument: CodexCellDocument | null = null; + try { + const pairedUri = provider.getPairedNotebookUri(document.uri); + if (pairedUri) { + const targetDocument = await provider.getOrOpenDocumentForUri(pairedUri); + const targetMilestones = targetDocument.buildMilestoneIndex(); + const targetRemoved = targetMilestones.milestones[milestoneIndex]; + const targetPrevious = targetMilestones.milestones[milestoneIndex - 1]; + + // Skip mirror unless the target has the same milestone neighbours + // backed by cells with matching IDs. We compare BOTH milestones' + // root cell ID lists so a previously-divergent pair (e.g. user + // mutated the target file independently) doesn't end up with a + // missing milestone on one side after the merge. + const removedCellId = targetRemoved + ? targetDocument.getCellByIndex(targetRemoved.cellIndex)?.metadata?.id + : undefined; + const removedIdMatches = removedCellId === removedMilestoneCellId; + const rootsMatchPrev = sourceAndTargetMilestoneRootsMatch( + document, + targetDocument, + milestoneIndex - 1 + ); + const rootsMatchRemoved = sourceAndTargetMilestoneRootsMatch( + document, + targetDocument, + milestoneIndex + ); + + if (!targetRemoved || !targetPrevious || !removedIdMatches || !rootsMatchPrev || !rootsMatchRemoved) { + console.warn(`${logPrefix} Source/target diverge; skipping structural mirror.`, { + milestoneIndex, + removedIdMatches, + rootsMatchPrev, + rootsMatchRemoved, + }); + } else { + const targetPreviousCell = targetDocument.getCellByIndex( + targetPrevious.cellIndex + ); + if (targetPreviousCell?.metadata?.id) { + await targetDocument.refreshAuthor(); + targetDocument.softDeleteCell(removedMilestoneCellId); + // Mirror the source's localized label/name map into the + // target's `subdivisionNamesFromSource` so the translator + // sees the merged section labels by default. The target's + // own `subdivisionNames` is left alone — translators are + // free to keep / override their per-side labels. + targetDocument.updateCellData(targetPreviousCell.metadata.id, { + subdivisions: merged.placements, + subdivisionNamesFromSource: mergedSourceNames, + }); + await provider.saveCustomDocument(targetDocument, cancellationToken); + await targetDocument.updateCellMilestoneIndices({ force: true }); + mirroredTargetDocument = targetDocument; + } + } + } + } catch (mirrorError) { + console.error(`${logPrefix} Failed to mirror to target:`, mirrorError); + } + + // Adjust the cached milestone position so the user lands on the survivor + // rather than a phantom index after the merge. If they were on the + // removed milestone, we shift to the previous one; if they were below + // it, we shift down by 1 to keep the same content in view. + const docUri = document.uri.toString(); + const cachedPosition = provider.currentMilestoneSubsectionMap.get(docUri); + if (cachedPosition && cachedPosition.milestoneIndex >= milestoneIndex) { + provider.currentMilestoneSubsectionMap.set(docUri, { + milestoneIndex: Math.max(0, cachedPosition.milestoneIndex - 1), + subsectionIndex: 0, + }); + } + + await sendMilestoneRefreshToWebview(document, webviewPanel, provider); + + if (mirroredTargetDocument) { + const targetUri = mirroredTargetDocument.uri.toString(); + const targetCachedPosition = + provider.currentMilestoneSubsectionMap.get(targetUri); + if ( + targetCachedPosition && + targetCachedPosition.milestoneIndex >= milestoneIndex + ) { + provider.currentMilestoneSubsectionMap.set(targetUri, { + milestoneIndex: Math.max(0, targetCachedPosition.milestoneIndex - 1), + subsectionIndex: 0, + }); + } + const targetPanel = provider.getWebviewPanelForUri(mirroredTargetDocument.uri); + if (targetPanel) { + try { + await sendMilestoneRefreshToWebview( + mirroredTargetDocument, + targetPanel, + provider + ); + } catch (refreshError) { + console.error( + `${logPrefix} Failed to refresh paired target webview:`, + refreshError + ); + } + } + } +} + +/** + * Inserts a new milestone cell at the given anchor inside an existing + * milestone, partitioning the existing placements + subdivisionNames across + * the new boundary, and mirroring the structural change to the paired target + * document by UUID. + * + * Used by both `addMilestoneAtCell` (anchor = N-th root cell, no pre-existing + * placement) and `promoteSubdivisionToMilestone` (anchor = an existing + * custom subdivision break). The two callers differ only in how they choose + * the anchor and the new milestone's label. + */ +async function commitSplitMilestoneAtAnchor({ + document, + webviewPanel, + provider, + milestoneIndex, + boundaryCellId, + newMilestoneLabel, + logPrefix, + errorPrefix, +}: { + document: CodexCellDocument; + webviewPanel: vscode.WebviewPanel; + provider: CodexCellEditorProvider; + milestoneIndex: number; + boundaryCellId: string; + /** + * Optional label for the new milestone cell. When provided we override + * the auto-derived `"BookName ChapterNumber"` default — typical when + * promoting a named subdivision: the user already has a label they + * meant for this section. + */ + newMilestoneLabel?: string; + logPrefix: string; + errorPrefix: string; +}): Promise { + if (!isSourceFileFlexible(document.uri)) { + console.warn( + `${logPrefix} Rejected write from non-source document:`, + document.uri.toString() + ); + vscode.window.showWarningMessage( + "Milestone placements can only be edited from the source file." + ); + return; + } + + const sourceMilestoneIndex = document.buildMilestoneIndex(); + const original = sourceMilestoneIndex.milestones[milestoneIndex]; + if (!original) { + console.error(`${logPrefix} Milestone not found at index`, milestoneIndex); + vscode.window.showErrorMessage( + `${errorPrefix}: milestone not found at index ${milestoneIndex}` + ); + return; + } + + const originalCell = document.getCellByIndex(original.cellIndex); + if ( + !originalCell || + originalCell.metadata?.type !== CodexCellTypes.MILESTONE || + !originalCell.metadata?.id + ) { + console.error(`${logPrefix} Invalid milestone cell`, original.cellIndex); + vscode.window.showErrorMessage(`${errorPrefix}: invalid milestone cell.`); + return; + } + + const rootIds = document.getRootContentCellIdsForMilestone(milestoneIndex); + const boundaryRootIndex = rootIds.indexOf(boundaryCellId); + if (boundaryRootIndex <= 0) { + // Boundary at index 0 would create an empty milestone before the + // anchor; -1 means the cell isn't even in this milestone (stale UI + // state). + console.warn(`${logPrefix} Boundary out of range`, { + boundaryCellId, + boundaryRootIndex, + rootCount: rootIds.length, + }); + vscode.window.showWarningMessage( + `${errorPrefix}: cannot split at this cell.` + ); + return; + } + + const originalData = readMilestoneSubdivisionData(originalCell); + const split = splitPlacementsAtAnchor( + originalData.placements, + rootIds, + boundaryCellId + ); + + // Partition subdivisionNames at the boundary so labels travel with their + // section. The implicit first subdivision (FIRST_SUBDIVISION_KEY) stays + // on the original milestone. The boundary cell's name (if any) becomes + // the new milestone's __start__ subdivision name. All other entries are + // sorted by whether their cell ID falls before or after the anchor. + const keepKeys = new Set([FIRST_SUBDIVISION_KEY]); + const moveKeys = new Map(); + for (let i = 0; i < boundaryRootIndex; i++) { + keepKeys.add(rootIds[i]); + } + moveKeys.set(boundaryCellId, FIRST_SUBDIVISION_KEY); + for (let i = boundaryRootIndex + 1; i < rootIds.length; i++) { + moveKeys.set(rootIds[i], rootIds[i]); + } + const sourceNamePartition = partitionSubdivisionNames( + originalData.subdivisionNames, + keepKeys, + moveKeys + ); + + // The label takes precedence on the milestone row. Fall back to the + // boundary placement's stored name if no explicit override was provided + // (typical when promoting a named subdivision via the dedicated path — + // the caller passes `newMilestoneLabel` directly. For ADD-AT-CELL the + // boundary usually has no name and we let `buildMilestoneCellPayload` + // derive a chapter-style default). + const fallbackBoundaryName = + split.boundaryName ?? sourceNamePartition.moved[FIRST_SUBDIVISION_KEY]; + const valueOverride = newMilestoneLabel || fallbackBoundaryName; + + // Newly-created milestone gets its own subdivisions/subdivisionNames + // populated atomically in the same `insertMilestoneCell` call, sparing + // us a follow-up updateCellData round-trip. + const newMilestoneInitialData: Record = {}; + if (split.after.length > 0) { + newMilestoneInitialData.subdivisions = split.after; + } + if (Object.keys(sourceNamePartition.moved).length > 0) { + newMilestoneInitialData.subdivisionNames = sourceNamePartition.moved; + } + + const cancellationToken = new vscode.CancellationTokenSource().token; + let insertedMilestoneCellId: string; + + try { + await document.refreshAuthor(); + const inserted = document.insertMilestoneCell({ + referenceCellId: boundaryCellId, + valueOverride, + initialData: newMilestoneInitialData, + }); + insertedMilestoneCellId = inserted.cellId; + document.updateCellData(originalCell.metadata.id, { + subdivisions: split.before, + subdivisionNames: sourceNamePartition.kept, + }); + await provider.saveCustomDocument(document, cancellationToken); + await document.updateCellMilestoneIndices({ force: true }); + } catch (error) { + console.error(`${logPrefix} Failed to update source:`, error); + vscode.window.showErrorMessage( + `${errorPrefix}: ${error instanceof Error ? error.message : String(error)}` + ); + return; + } + + let mirroredTargetDocument: CodexCellDocument | null = null; + try { + const pairedUri = provider.getPairedNotebookUri(document.uri); + if (pairedUri) { + const targetDocument = await provider.getOrOpenDocumentForUri(pairedUri); + // Match the source's pre-split milestoneIndex on the target. We + // rebuild the target's milestone index here because the source + // document's index has already been mutated; we can't use the + // source's `original` info anymore. + const targetMilestones = targetDocument.buildMilestoneIndex(); + // After the source insert, source's milestone count = target's + 1. + // We mirror against the SAME starting milestone index on the + // target (it hasn't been mutated yet), which corresponds to the + // same `milestoneIndex` parameter on this side too. + const targetOriginal = targetMilestones.milestones[milestoneIndex]; + if (!targetOriginal) { + console.warn(`${logPrefix} Target has no milestone at index, skipping mirror.`, { + milestoneIndex, + }); + } else { + const targetOriginalCell = targetDocument.getCellByIndex( + targetOriginal.cellIndex + ); + const sharedOriginalId = + targetOriginalCell?.metadata?.id === originalCell.metadata.id; + // The source's pre-split root IDs (captured in `rootIds` + // before we mutated) must equal the target's CURRENT root + // IDs. We can't reuse `sourceAndTargetMilestoneRootsMatch` + // here because the source has already been mutated, so its + // milestone[milestoneIndex] roots are now just the BEFORE + // partition. + const targetRootIds = targetDocument.getRootContentCellIdsForMilestone( + milestoneIndex + ); + let rootsMatch = targetRootIds.length === rootIds.length; + if (rootsMatch) { + for (let i = 0; i < rootIds.length; i++) { + if (rootIds[i] !== targetRootIds[i]) { + rootsMatch = false; + break; + } + } + } + + if (!sharedOriginalId || !rootsMatch) { + console.warn(`${logPrefix} Source/target diverge; skipping structural mirror.`, { + milestoneIndex, + sharedOriginalId, + rootsMatch, + }); + } else if (targetOriginalCell?.metadata?.id) { + const targetData = readMilestoneSubdivisionData(targetOriginalCell); + const targetNamePartition = partitionSubdivisionNames( + targetData.subdivisionNames, + keepKeys, + moveKeys + ); + const targetSourceNamePartition = partitionSubdivisionNames( + targetData.subdivisionNamesFromSource, + keepKeys, + moveKeys + ); + + const targetInitialData: Record = {}; + if (split.after.length > 0) { + targetInitialData.subdivisions = split.after; + } + if (Object.keys(targetNamePartition.moved).length > 0) { + targetInitialData.subdivisionNames = targetNamePartition.moved; + } + // Mirror the SOURCE's name partition into the target's + // `subdivisionNamesFromSource` so the target inherits + // source-side labels for the new milestone by default. + const mergedSourceNamesForNewMilestone = { + ...targetSourceNamePartition.moved, + ...sourceNamePartition.moved, + }; + if (Object.keys(mergedSourceNamesForNewMilestone).length > 0) { + targetInitialData.subdivisionNamesFromSource = + mergedSourceNamesForNewMilestone; + } + + await targetDocument.refreshAuthor(); + targetDocument.insertMilestoneCell({ + newCellId: insertedMilestoneCellId, + referenceCellId: boundaryCellId, + valueOverride, + initialData: targetInitialData, + }); + // Update the original target milestone with the BEFORE + // partition of placements + names. Mirror the source's + // BEFORE-partition names map into the target's + // subdivisionNamesFromSource so cross-side rendering + // stays consistent. + const mergedSourceNamesForOriginal = { + ...targetSourceNamePartition.kept, + ...sourceNamePartition.kept, + }; + targetDocument.updateCellData(targetOriginalCell.metadata.id, { + subdivisions: split.before, + subdivisionNames: targetNamePartition.kept, + subdivisionNamesFromSource: mergedSourceNamesForOriginal, + }); + await provider.saveCustomDocument(targetDocument, cancellationToken); + await targetDocument.updateCellMilestoneIndices({ force: true }); + mirroredTargetDocument = targetDocument; + } + } + } + } catch (mirrorError) { + console.error(`${logPrefix} Failed to mirror to target:`, mirrorError); + } + + // Adjust cached position so a user viewing milestone N+ stays on the + // equivalent content after the split. Viewers on the original milestone + // (index === milestoneIndex) stay put — the original is now the first + // half, which still makes sense as their current position. + const docUri = document.uri.toString(); + const cachedPosition = provider.currentMilestoneSubsectionMap.get(docUri); + if (cachedPosition && cachedPosition.milestoneIndex > milestoneIndex) { + provider.currentMilestoneSubsectionMap.set(docUri, { + milestoneIndex: cachedPosition.milestoneIndex + 1, + subsectionIndex: cachedPosition.subsectionIndex, + }); + } + + await sendMilestoneRefreshToWebview(document, webviewPanel, provider); + + if (mirroredTargetDocument) { + const targetUri = mirroredTargetDocument.uri.toString(); + const targetCachedPosition = + provider.currentMilestoneSubsectionMap.get(targetUri); + if ( + targetCachedPosition && + targetCachedPosition.milestoneIndex > milestoneIndex + ) { + provider.currentMilestoneSubsectionMap.set(targetUri, { + milestoneIndex: targetCachedPosition.milestoneIndex + 1, + subsectionIndex: targetCachedPosition.subsectionIndex, + }); + } + const targetPanel = provider.getWebviewPanelForUri(mirroredTargetDocument.uri); + if (targetPanel) { + try { + await sendMilestoneRefreshToWebview( + mirroredTargetDocument, + targetPanel, + provider + ); + } catch (refreshError) { + console.error( + `${logPrefix} Failed to refresh paired target webview:`, + refreshError + ); + } + } + } +} + /** * Helper function to get the audio file path for a cell * Checks metadata attachments first, then falls back to filesystem lookup @@ -1855,6 +2507,144 @@ const messageHandlers: Record Promise { + const typedEvent = event as Extract< + EditorPostMessages, + { command: "addMilestoneAtCell"; } + >; + debug("addMilestoneAtCell message received", { event }); + + const { milestoneIndex, cellNumber } = typedEvent.content; + const rootIds = document.getRootContentCellIdsForMilestone(milestoneIndex); + if ( + !Array.isArray(rootIds) || + rootIds.length === 0 || + typeof cellNumber !== "number" || + !Number.isFinite(cellNumber) || + cellNumber < 2 || + cellNumber > rootIds.length + ) { + console.warn("[addMilestoneAtCell] cellNumber out of range:", { + milestoneIndex, + cellNumber, + rootCount: rootIds?.length ?? 0, + }); + vscode.window.showWarningMessage( + rootIds && rootIds.length >= 2 + ? `Cannot add a milestone here — pick a cell between 2 and ${rootIds.length}.` + : "This milestone is too short to split." + ); + return; + } + + const boundaryCellId = rootIds[cellNumber - 1]; + await commitSplitMilestoneAtAnchor({ + document, + webviewPanel, + provider, + milestoneIndex, + boundaryCellId, + logPrefix: "[addMilestoneAtCell]", + errorPrefix: "Failed to add milestone", + }); + }, + + promoteSubdivisionToMilestone: async ({ event, document, webviewPanel, provider }) => { + const typedEvent = event as Extract< + EditorPostMessages, + { command: "promoteSubdivisionToMilestone"; } + >; + debug("promoteSubdivisionToMilestone message received", { event }); + + const { milestoneIndex, subdivisionKey } = typedEvent.content; + if (subdivisionKey === FIRST_SUBDIVISION_KEY) { + console.warn( + "[promoteSubdivisionToMilestone] Cannot promote the implicit first subdivision (it's already aligned with the milestone start)" + ); + vscode.window.showWarningMessage( + "The first subdivision is already at the milestone boundary." + ); + return; + } + + const rootIds = document.getRootContentCellIdsForMilestone(milestoneIndex); + if (!rootIds.includes(subdivisionKey)) { + console.warn("[promoteSubdivisionToMilestone] Subdivision key not in milestone roots", { + milestoneIndex, + subdivisionKey, + }); + vscode.window.showWarningMessage( + "Cannot promote that subdivision — it does not match a current cell in this milestone." + ); + return; + } + + // Look up the existing placement's resolved name so it becomes the + // promoted milestone's label. Prefer the document-local + // `subdivisionNames` override (matches what the user sees in the + // accordion) and fall back to the placement's inline `.name`. + const milestoneIndexObj = document.buildMilestoneIndex(); + const milestone = milestoneIndexObj.milestones[milestoneIndex]; + const milestoneCell = milestone + ? document.getCellByIndex(milestone.cellIndex) + : undefined; + const existing = milestoneCell ? readMilestoneSubdivisionData(milestoneCell) : { + placements: [], + subdivisionNames: {}, + subdivisionNamesFromSource: {}, + }; + const inlineName = existing.placements.find( + (p) => p.startCellId === subdivisionKey + )?.name; + const promotedLabel = + existing.subdivisionNames[subdivisionKey] || inlineName || undefined; + + await commitSplitMilestoneAtAnchor({ + document, + webviewPanel, + provider, + milestoneIndex, + boundaryCellId: subdivisionKey, + newMilestoneLabel: promotedLabel, + logPrefix: "[promoteSubdivisionToMilestone]", + errorPrefix: "Failed to promote subdivision", + }); + }, + + removeMilestone: async ({ event, document, webviewPanel, provider }) => { + const typedEvent = event as Extract< + EditorPostMessages, + { command: "removeMilestone"; } + >; + debug("removeMilestone message received", { event }); + await commitMergeMilestoneIntoPrevious({ + document, + webviewPanel, + provider, + milestoneIndex: typedEvent.content.milestoneIndex, + preserveBoundary: false, + logPrefix: "[removeMilestone]", + errorPrefix: "Failed to remove milestone", + }); + }, + + demoteMilestoneToSubdivision: async ({ event, document, webviewPanel, provider }) => { + const typedEvent = event as Extract< + EditorPostMessages, + { command: "demoteMilestoneToSubdivision"; } + >; + debug("demoteMilestoneToSubdivision message received", { event }); + await commitMergeMilestoneIntoPrevious({ + document, + webviewPanel, + provider, + milestoneIndex: typedEvent.content.milestoneIndex, + preserveBoundary: true, + logPrefix: "[demoteMilestoneToSubdivision]", + errorPrefix: "Failed to demote milestone", + }); + }, + updateNotebookMetadata: async ({ event, document, webviewPanel, provider }) => { const typedEvent = event as Extract; debug("updateNotebookMetadata message received", { event }); diff --git a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts index 06d87a9a5..4820987cf 100755 --- a/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorProvider.ts @@ -146,6 +146,18 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider 0 ? Math.floor(raw) : 0; } + /** + * User opt-in for the milestone-placement editing controls (add / remove + * / promote / demote). Off by default — the feature is gated because it + * restructures the document. Pushed to webviews on initial paint and on + * the workspace configuration change event below so the controls toggle + * live without a reload. + */ + private get ENABLE_MILESTONE_PLACEMENT_EDITING(): boolean { + const config = vscode.workspace.getConfiguration("codex-editor-extension"); + return config.get("enableMilestonePlacementEditing", false); + } + private bumpDocumentRevision(documentUri: string): number { const next = (this.documentRevisions.get(documentUri) ?? 0) + 1; this.documentRevisions.set(documentUri, next); @@ -377,6 +389,20 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider { + safePostMessageToPanel(panel, { + type: "updateMilestonePlacementEditingPreference", + enableMilestonePlacementEditing: newPref, + }); + }); + } }); this.context.subscriptions.push(configurationChangeDisposable); @@ -967,6 +993,8 @@ export class CodexCellEditorProvider implements vscode.CustomEditorProvider= 2). */ + cellNumber: number; + }; + } + | { + /** + * Soft-delete a milestone cell. The previous (surviving) milestone's + * range expands to absorb the removed milestone's content cells; any + * subdivision breaks inside the removed milestone are lifted onto the + * surviving milestone (their cell-ID anchors stay valid). The + * boundary itself is NOT preserved as a subdivision break — use + * `demoteMilestoneToSubdivision` for that. + * + * The first milestone cannot be removed (would leave the document + * with a virtual milestone). Source-only. + */ + command: "removeMilestone"; + content: { + /** 0-based milestone index in the current source document. */ + milestoneIndex: number; + }; + } + | { + /** + * Convert an existing custom subdivision break into a full milestone. + * The original milestone splits at the subdivision's anchor cell and + * the named subdivision becomes the new milestone's label. Equivalent + * to `addMilestoneAtCell` against an already-promoted anchor, except + * the source's existing placement at the anchor is removed (since + * it's now a milestone, not a subdivision break). + * + * Source-only. The subdivision must currently exist as a `custom` + * placement on the milestone's stored `subdivisions` list. + */ + command: "promoteSubdivisionToMilestone"; + content: { + milestoneIndex: number; + /** Stable subdivision key — the `startCellId` of the placement. */ + subdivisionKey: string; + }; + } + | { + /** + * Convert a milestone into a subdivision break of the previous + * milestone. Like `removeMilestone`, but the boundary is preserved + * as a custom subdivision (carrying the demoted milestone's label + * as its name). Source-only; cannot demote the first milestone. + */ + command: "demoteMilestoneToSubdivision"; + content: { + milestoneIndex: number; + }; + } | { command: "refreshWebviewAfterMilestoneEdits"; content?: Record; @@ -1997,6 +2064,14 @@ type EditorReceiveMessages = * setting `codex-editor-extension.useSubdivisionNumberLabels`. */ useSubdivisionNumberLabels?: boolean; + /** + * When true, the milestone-placement editing controls render in the + * MilestoneAccordion's settings mode (add/remove/promote/demote). + * Mirrors `codex-editor-extension.enableMilestonePlacementEditing`. + * Off by default — the feature is gated behind a setting because it + * restructures the document, not just relabels regions. + */ + enableMilestonePlacementEditing?: boolean; } | { type: "providerSendsCellPage"; @@ -2294,6 +2369,15 @@ type EditorReceiveMessages = */ useSubdivisionNumberLabels: boolean; } + | { + type: "updateMilestonePlacementEditingPreference"; + /** + * When true, the MilestoneAccordion's settings mode reveals + * milestone-placement editing controls (add/remove/promote/demote). + * Mirrors `codex-editor-extension.enableMilestonePlacementEditing`. + */ + enableMilestonePlacementEditing: boolean; + } | { type: "editorPosition"; position: "leftmost" | "rightmost" | "center" | "single" | "unknown"; diff --git a/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx b/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx index 9ca2de5d8..1c36c8557 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx @@ -95,6 +95,12 @@ interface ChapterNavigationHeaderProps { * when a user-assigned name is available. Defaults to false. */ useSubdivisionNumberLabels?: boolean; + /** + * When true, the milestone-placement editing controls render in the + * MilestoneAccordion's settings mode (add/remove/promote/demote). + * Defaults to false; gated behind `codex-editor-extension.enableMilestonePlacementEditing`. + */ + enableMilestonePlacementEditing?: boolean; } export function ChapterNavigationHeader({ @@ -155,6 +161,7 @@ export function ChapterNavigationHeader({ allSubsectionProgress, requestSubsectionProgress, useSubdivisionNumberLabels = false, + enableMilestonePlacementEditing = false, }: // Removed onToggleCorrectionEditor since it will be a VS Code command now ChapterNavigationHeaderProps) { const [showConfirm, setShowConfirm] = useState(false); @@ -1085,6 +1092,7 @@ ChapterNavigationHeaderProps) { requestSubsectionProgress={requestSubsectionProgress} vscode={vscode} useSubdivisionNumberLabels={useSubdivisionNumberLabels} + enableMilestonePlacementEditing={enableMilestonePlacementEditing} />
); diff --git a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx index b2976bc9f..ce7805253 100755 --- a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx @@ -247,6 +247,13 @@ const CodexCellEditor: React.FC = () => { // `updateSubdivisionLabelPreference` messages; default false when absent. const [useSubdivisionNumberLabels, setUseSubdivisionNumberLabels] = useState(false); + // Workspace opt-in for milestone-placement editing controls + // (add/remove/promote/demote). Initialized from the provider's first + // content payload and kept in sync via + // `updateMilestonePlacementEditingPreference` messages; default false. + const [enableMilestonePlacementEditing, setEnableMilestonePlacementEditing] = + useState(false); + // Track cells currently transcribing audio (to show the same loading effect as translations) const [transcribingCells, setTranscribingCells] = useState>(new Set()); @@ -2343,6 +2350,11 @@ const CodexCellEditor: React.FC = () => { Boolean(event.data.useSubdivisionNumberLabels) ); } + if (event.data.enableMilestonePlacementEditing !== undefined) { + setEnableMilestonePlacementEditing( + Boolean(event.data.enableMilestonePlacementEditing) + ); + } } if (event.data.type === "updateSubdivisionLabelPreference") { @@ -2350,6 +2362,12 @@ const CodexCellEditor: React.FC = () => { Boolean(event.data.useSubdivisionNumberLabels) ); } + + if (event.data.type === "updateMilestonePlacementEditingPreference") { + setEnableMilestonePlacementEditing( + Boolean(event.data.enableMilestonePlacementEditing) + ); + } }, [] ); @@ -3233,6 +3251,7 @@ const CodexCellEditor: React.FC = () => { allSubsectionProgress={subsectionProgress} requestSubsectionProgress={requestSubsectionProgressForMilestone} useSubdivisionNumberLabels={useSubdivisionNumberLabels} + enableMilestonePlacementEditing={enableMilestonePlacementEditing} />
diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx index f1305cb60..e51d5bc3f 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -10,7 +10,17 @@ import { import { ProgressDots } from "./ProgressDots"; import { deriveSubsectionPercentages, getProgressDisplay } from "../utils/progressUtils"; import MicrophoneIcon from "../../components/ui/icons/MicrophoneIcon"; -import { Languages, Check, RotateCcw, X, Undo2, Plus, Trash2 } from "lucide-react"; +import { + Languages, + Check, + RotateCcw, + X, + Undo2, + Plus, + Trash2, + ArrowUpFromLine, + ArrowDownToLine, +} from "lucide-react"; import type { Subsection, ProgressPercentages } from "../../lib/types"; import type { MilestoneIndex, MilestoneInfo } from "../../../../../types"; import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"; @@ -61,6 +71,13 @@ interface MilestoneAccordionProps { * `false`, matching the read-only default UX. */ initialSettingsMode?: boolean; + /** + * Workspace opt-in for milestone-placement editing controls + * (add/remove/promote/demote). When false the structural buttons are + * hidden even in settings mode. Mirrors + * `codex-editor-extension.enableMilestonePlacementEditing`. + */ + enableMilestonePlacementEditing?: boolean; } export function MilestoneAccordion({ @@ -80,6 +97,7 @@ export function MilestoneAccordion({ vscode, useSubdivisionNumberLabels = false, initialSettingsMode = false, + enableMilestonePlacementEditing = false, }: MilestoneAccordionProps) { // Layout constants const DROPDOWN_MAX_HEIGHT_VIEWPORT_PERCENT = 60; // 60vh @@ -149,10 +167,37 @@ export function MilestoneAccordion({ const [addBreakError, setAddBreakError] = useState(""); const addBreakInputRef = useRef(null); + // "Add milestone" form state — independent from the subdivision form so + // the user can switch between the two without the second form forgetting + // the value they typed. Same string-typed input pattern. + const [addMilestoneMilestoneIdx, setAddMilestoneMilestoneIdx] = useState(null); + const [addMilestoneCellNumber, setAddMilestoneCellNumber] = useState(""); + const [addMilestoneError, setAddMilestoneError] = useState(""); + const addMilestoneInputRef = useRef(null); + + // Two-click confirmation for the milestone trash + demote actions. Same + // shape as `resetConfirmMilestoneIdx` so we can share the timer logic. + const [removeConfirmMilestoneIdx, setRemoveConfirmMilestoneIdx] = useState(null); + const removeConfirmTimeoutRef = useRef(null); + const [demoteConfirmMilestoneIdx, setDemoteConfirmMilestoneIdx] = useState(null); + const demoteConfirmTimeoutRef = useRef(null); + useEffect(() => { + // Snapshot the refs at effect-entry so the cleanup closure doesn't + // capture stale `.current` values across rerenders (and so eslint's + // exhaustive-deps doesn't flag them). + const resetTimer = resetConfirmTimeoutRef; + const removeTimer = removeConfirmTimeoutRef; + const demoteTimer = demoteConfirmTimeoutRef; return () => { - if (resetConfirmTimeoutRef.current !== null) { - window.clearTimeout(resetConfirmTimeoutRef.current); + if (resetTimer.current !== null) { + window.clearTimeout(resetTimer.current); + } + if (removeTimer.current !== null) { + window.clearTimeout(removeTimer.current); + } + if (demoteTimer.current !== null) { + window.clearTimeout(demoteTimer.current); } }; }, []); @@ -165,6 +210,12 @@ export function MilestoneAccordion({ } }, [addBreakMilestoneIdx]); + useEffect(() => { + if (addMilestoneMilestoneIdx !== null) { + addMilestoneInputRef.current?.focus(); + } + }, [addMilestoneMilestoneIdx]); + /** * Rebuilds the milestone's placement list from its resolved subdivisions. * Only subdivisions at index > 0 with `source === "custom"` and a valid @@ -298,6 +349,160 @@ export function MilestoneAccordion({ handleCancelAddBreak(); }; + const handleOpenAddMilestone = ( + e: React.MouseEvent, + milestoneIdx: number + ) => { + e.stopPropagation(); + if (!isSourceText) return; + setAddMilestoneMilestoneIdx(milestoneIdx); + setAddMilestoneCellNumber(""); + setAddMilestoneError(""); + // Close the sibling subdivision form so only one is on screen at a + // time — keeps the layout calm and the focus path predictable. + setAddBreakMilestoneIdx(null); + }; + + const handleCancelAddMilestone = (e?: React.MouseEvent) => { + e?.stopPropagation(); + setAddMilestoneMilestoneIdx(null); + setAddMilestoneCellNumber(""); + setAddMilestoneError(""); + }; + + const handleSubmitAddMilestone = ( + e: React.MouseEvent | React.FormEvent, + milestoneIdx: number, + maxCellNumber: number + ) => { + e.preventDefault(); + e.stopPropagation(); + if (!isSourceText) return; + const trimmed = addMilestoneCellNumber.trim(); + const parsed = Number(trimmed); + if ( + trimmed.length === 0 || + !Number.isFinite(parsed) || + !Number.isInteger(parsed) || + parsed < 2 || + parsed > maxCellNumber + ) { + setAddMilestoneError( + maxCellNumber >= 2 + ? `Enter a number between 2 and ${maxCellNumber}.` + : "This milestone is too short to split." + ); + return; + } + vscode.postMessage({ + command: "addMilestoneAtCell", + content: { + milestoneIndex: milestoneIdx, + cellNumber: parsed, + }, + }); + handleCancelAddMilestone(); + }; + + const handlePromoteSubdivision = ( + e: React.MouseEvent, + milestoneIdx: number, + subsection: Subsection + ) => { + e.stopPropagation(); + if (!isSourceText) return; + if (!enableMilestonePlacementEditing) return; + if (!subsection.startCellId || subsection.source !== "custom") return; + // The implicit first subdivision shares its key with the milestone + // start; promoting it would create an empty milestone and drop the + // boundary anchor. Surface this defensively even though the button + // is hidden in the UI for the first subdivision. + if (subsection.startIndex === 0) return; + vscode.postMessage({ + command: "promoteSubdivisionToMilestone", + content: { + milestoneIndex: milestoneIdx, + subdivisionKey: subsection.startCellId, + }, + }); + }; + + /** + * Two-click confirmation pattern shared with `handleResetSubdivisionsClick`: + * first click arms the action with a 3-second auto-disarm window; second + * click within the window commits. + */ + const armOrCommit = ( + milestoneIdx: number, + currentArmed: number | null, + setArmed: (idx: number | null) => void, + timeoutRef: React.MutableRefObject, + commit: () => void + ): boolean => { + if (currentArmed !== milestoneIdx) { + setArmed(milestoneIdx); + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + } + timeoutRef.current = window.setTimeout(() => { + setArmed(null); + timeoutRef.current = null; + }, 3000); + return false; + } + if (timeoutRef.current !== null) { + window.clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setArmed(null); + commit(); + return true; + }; + + const handleRemoveMilestoneClick = ( + e: React.MouseEvent, + milestoneIdx: number + ) => { + e.stopPropagation(); + if (!isSourceText) return; + if (!enableMilestonePlacementEditing) return; + if (milestoneIdx === 0) return; + armOrCommit( + milestoneIdx, + removeConfirmMilestoneIdx, + setRemoveConfirmMilestoneIdx, + removeConfirmTimeoutRef, + () => { + vscode.postMessage({ + command: "removeMilestone", + content: { milestoneIndex: milestoneIdx }, + }); + } + ); + }; + + const handleDemoteMilestoneClick = ( + e: React.MouseEvent, + milestoneIdx: number + ) => { + e.stopPropagation(); + if (!isSourceText) return; + if (!enableMilestonePlacementEditing) return; + if (milestoneIdx === 0) return; + armOrCommit( + milestoneIdx, + demoteConfirmMilestoneIdx, + setDemoteConfirmMilestoneIdx, + demoteConfirmTimeoutRef, + () => { + vscode.postMessage({ + command: "demoteMilestoneToSubdivision", + content: { milestoneIndex: milestoneIdx }, + }); + } + ); + }; + // Calculate position and dimensions const calculatePositionAndDimensions = () => { if (isOpen && anchorRef.current) { @@ -1093,15 +1298,85 @@ export function MilestoneAccordion({ >
- + {enableMilestonePlacementEditing && + isSourceText && + milestoneIdx > 0 ? ( + <> + + handleDemoteMilestoneClick( + e, + milestoneIdx + ) + } + className={ + demoteConfirmMilestoneIdx === + milestoneIdx + ? "bg-inputValidation-warningBackground" + : undefined + } + > + + + + handleRemoveMilestoneClick( + e, + milestoneIdx + ) + } + className={ + removeConfirmMilestoneIdx === + milestoneIdx + ? "bg-inputValidation-warningBackground" + : undefined + } + > + + + + ) : ( + /* Greyed-out ghost trash — keeps the + row spacing identical for the + first milestone (which can never + be removed) and when the feature + flag is off / on a target file. */ + + )} )}
)} + {isSettingsMode && + enableMilestonePlacementEditing && + isSourceText && + subsection.source === + "custom" && + subsection.startCellId && + subsection.startIndex > + 0 && ( + + handlePromoteSubdivision( + e, + milestoneIdx, + subsection + ) + } + > + + + )} {isSettingsMode && (isSourceText && subsection.source === @@ -1356,10 +1654,20 @@ export function MilestoneAccordion({ const canAddBreak = maxCellNumber >= 2; const isFormOpen = addBreakMilestoneIdx === milestoneIdx; + const isMilestoneFormOpen = + addMilestoneMilestoneIdx === + milestoneIdx; + const canAddMilestone = + enableMilestonePlacementEditing && + canAddBreak; const hasCustomBreaks = subsections.some( (s) => s.source === "custom" ); - if (!canAddBreak && !hasCustomBreaks) { + if ( + !canAddBreak && + !canAddMilestone && + !hasCustomBreaks + ) { return null; } return ( @@ -1474,6 +1782,126 @@ export function MilestoneAccordion({ ) )} + {/* Milestone-placement editing form / button. + Only renders when the workspace setting is on and + the parent has enough cells to split. Mutually + exclusive with the subdivision form so only one + is open at a time per milestone. */} + {isMilestoneFormOpen ? ( +
+ handleSubmitAddMilestone( + e, + milestoneIdx, + maxCellNumber + ) + } + className="flex flex-wrap items-center gap-2" + > + + { + setAddMilestoneCellNumber( + e.target.value + ); + if ( + addMilestoneError + ) + setAddMilestoneError( + "" + ); + }} + onKeyDown={(e) => { + if ( + e.key === + "Escape" + ) { + e.preventDefault(); + handleCancelAddMilestone(); + } + }} + aria-label="Cell number for new milestone" + aria-describedby={ + addMilestoneError + ? `add-milestone-error-${milestoneIdx}` + : undefined + } + aria-invalid={ + !!addMilestoneError + } + placeholder="322" + className="w-20 text-xs px-2 py-1 rounded border border-[var(--vscode-input-border)] bg-[var(--vscode-input-background)] text-[var(--vscode-input-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--vscode-focusBorder)]" + /> + + + {addMilestoneError && ( + + {addMilestoneError} + + )} +
+ ) : ( + canAddMilestone && + !isFormOpen && ( + + ) + )} {hasCustomBreaks && !isFormOpen && (
+ + {/* Milestone placement editing */} +
+
+
+ Edit milestone placement +
+
+ Show controls in the milestone accordion to add, remove, + promote, or demote milestones on source files. Edits mirror + to the paired target. +
+
+ +
From 26049721f17779cd3239703adaae6ec926c7c858 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Thu, 7 May 2026 17:00:24 -0500 Subject: [PATCH 23/34] Test milestone placement edits and the new UI gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mocha (extension host): - `splitPlacementsAtAnchor` and `mergePlacementsForRemovedMilestone` cover boundary lifting (named subdivision → first-subdivision override on the new milestone), the `preserveBoundary` toggle that separates `removeMilestone` from `demoteMilestoneToSubdivision`, and the cell-ID partition by root order. - The four structural-edit handlers exercise the happy path (partition + mirror), the source-only guard (warns + no-op on target), the first-milestone refusal for remove/demote, and the promoted-from-named-subdivision label takeover. Vitest (webview): - `MilestoneAccordion` placement-editing controls are scoped to the feature setting + `isSourceText` + settings mode. Verifies the `addMilestoneAtCell` form payload, the promote button's `subdivisionKey` payload, the two-click arming for remove/demote, and that the first milestone never exposes destructive actions. --- src/test/suite/milestoneSubdivisions.test.ts | 460 ++++++++++++++++++ .../components/MilestoneAccordion.test.tsx | 212 ++++++++ 2 files changed, 672 insertions(+) diff --git a/src/test/suite/milestoneSubdivisions.test.ts b/src/test/suite/milestoneSubdivisions.test.ts index 40eec1a9d..b3dcfb89b 100644 --- a/src/test/suite/milestoneSubdivisions.test.ts +++ b/src/test/suite/milestoneSubdivisions.test.ts @@ -6,7 +6,9 @@ import { CodexCellTypes } from "../../../types/enums"; import { FIRST_SUBDIVISION_KEY, findSubdivisionIndexForRoot, + mergePlacementsForRemovedMilestone, resolveSubdivisions, + splitPlacementsAtAnchor, } from "../../providers/codexCellEditorProvider/utils/subdivisionUtils"; import { __testOnlyMessageHandlers } from "../../providers/codexCellEditorProvider/codexCellEditorMessagehandling"; import { @@ -1020,4 +1022,462 @@ suite("Milestone Subdivisions Test Suite", () => { assert.strictEqual(document.getCellsForMilestone(0, 2, 50).length, 25); }); }); + + // --------------------------------------------------------------------------- + // Pure-function tests for the milestone-placement edit redistribution helpers + // --------------------------------------------------------------------------- + + suite("splitPlacementsAtAnchor()", () => { + const rootIds = ["v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "v9", "v10"]; + + test("partitions placements strictly before / after the anchor", () => { + const result = splitPlacementsAtAnchor( + [ + { startCellId: "v3", name: "Pre" }, + { startCellId: "v8", name: "Post" }, + ], + rootIds, + "v6" + ); + assert.deepStrictEqual(result.before, [{ startCellId: "v3", name: "Pre" }]); + assert.deepStrictEqual(result.after, [{ startCellId: "v8", name: "Post" }]); + assert.strictEqual(result.boundaryName, undefined); + }); + + test("placement at the anchor surfaces as boundaryName, not as a placement", () => { + const result = splitPlacementsAtAnchor( + [ + { startCellId: "v3" }, + { startCellId: "v6", name: "Mid Section" }, + { startCellId: "v9" }, + ], + rootIds, + "v6" + ); + assert.deepStrictEqual(result.before, [{ startCellId: "v3" }]); + assert.deepStrictEqual(result.after, [{ startCellId: "v9" }]); + assert.strictEqual(result.boundaryName, "Mid Section"); + }); + + test("stale anchors are silently dropped", () => { + const result = splitPlacementsAtAnchor( + [ + { startCellId: "v3" }, + { startCellId: "ghost" }, + { startCellId: "v8" }, + ], + rootIds, + "v6" + ); + assert.strictEqual(result.before.length, 1); + assert.strictEqual(result.after.length, 1); + }); + + test("anchor at index 0 returns input unchanged in `before`", () => { + const result = splitPlacementsAtAnchor( + [{ startCellId: "v3" }], + rootIds, + "v1" + ); + assert.deepStrictEqual(result.before, [{ startCellId: "v3" }]); + assert.strictEqual(result.after.length, 0); + }); + + test("anchor not in rootIds returns empty result", () => { + const result = splitPlacementsAtAnchor( + [{ startCellId: "v3" }], + rootIds, + "ghost" + ); + assert.deepStrictEqual(result, { + before: [{ startCellId: "v3" }], + after: [], + }); + }); + + test("undefined placements yield empty partitions", () => { + const result = splitPlacementsAtAnchor(undefined, rootIds, "v5"); + assert.deepStrictEqual(result.before, []); + assert.deepStrictEqual(result.after, []); + }); + + test("duplicate placements are deduped (first wins)", () => { + const result = splitPlacementsAtAnchor( + [ + { startCellId: "v3", name: "First" }, + { startCellId: "v3", name: "Second" }, + { startCellId: "v8" }, + ], + rootIds, + "v6" + ); + assert.strictEqual(result.before.length, 1); + assert.strictEqual(result.before[0].name, "First"); + }); + }); + + suite("mergePlacementsForRemovedMilestone()", () => { + test("preserveBoundary=true stamps boundary placement with the milestone label", () => { + const result = mergePlacementsForRemovedMilestone({ + prevPlacements: [{ startCellId: "v3" }], + removedPlacements: [{ startCellId: "v9" }], + boundaryAnchorCellId: "v7", + boundaryName: "Luke 2", + preserveBoundary: true, + }); + assert.deepStrictEqual(result.placements, [ + { startCellId: "v3" }, + { startCellId: "v7", name: "Luke 2" }, + { startCellId: "v9" }, + ]); + }); + + test("preserveBoundary=false omits the boundary placement entirely", () => { + const result = mergePlacementsForRemovedMilestone({ + prevPlacements: [{ startCellId: "v3" }], + removedPlacements: [{ startCellId: "v9", name: "Tail" }], + boundaryAnchorCellId: "v7", + boundaryName: "Luke 2", + preserveBoundary: false, + }); + assert.deepStrictEqual(result.placements, [ + { startCellId: "v3" }, + { startCellId: "v9", name: "Tail" }, + ]); + }); + + test("removed-side name on boundary cell wins over preserveBoundary's milestone label", () => { + // When the demoted milestone already had an explicit name on its + // own first cell (in `subdivisions[]`), that name takes precedence + // because it's more specific than the milestone label. + const result = mergePlacementsForRemovedMilestone({ + prevPlacements: [], + removedPlacements: [ + { startCellId: "v7", name: "Specific Name" }, + ], + boundaryAnchorCellId: "v7", + boundaryName: "Luke 2", + preserveBoundary: true, + }); + assert.strictEqual(result.placements.length, 1); + assert.strictEqual(result.placements[0].name, "Specific Name"); + }); + + test("dedupes by startCellId across both sources", () => { + const result = mergePlacementsForRemovedMilestone({ + prevPlacements: [{ startCellId: "v3", name: "A" }], + removedPlacements: [{ startCellId: "v3", name: "B" }], + boundaryAnchorCellId: "v7", + preserveBoundary: false, + }); + assert.strictEqual(result.placements.length, 1); + assert.strictEqual(result.placements[0].name, "B", "removed-side overwrites prev for same key"); + }); + }); + + // --------------------------------------------------------------------------- + // Handler integration tests for the milestone-placement edit pipeline. + // --------------------------------------------------------------------------- + + suite("Milestone placement edit handlers", () => { + function stampSourceUri(document: CodexCellDocument) { + (document as any).uri = vscode.Uri.parse("file:///test.source"); + } + + function stubProviderForHandlerTest(p: CodexCellEditorProvider) { + sinon.stub(p, "saveCustomDocument").resolves(); + sinon.stub(p, "getPairedNotebookUri").returns(null); + sinon.stub(p, "refreshWebview").resolves(); + (p as any).currentMilestoneSubsectionMap = new Map(); + sinon.stub(CodexCellDocument.prototype as any, "refreshAuthor").resolves(); + // Avoid SQLite-mirror writes during structural-edit tests; the + // milestone-index reflush isn't observable in the in-memory + // assertions below and the stubs above already disable database + // I/O. + sinon.stub( + CodexCellDocument.prototype as any, + "updateCellMilestoneIndices" + ).resolves(); + } + + /** Builds a single-milestone document with N text cells (v1..vN). */ + function buildMilestoneWithRoots(rootCount: number, milestoneId = "m1", milestoneValue = "Luke 1") { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: milestoneValue, + metadata: { type: CodexCellTypes.MILESTONE, id: milestoneId }, + }, + ]; + for (let i = 1; i <= rootCount; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + return cells; + } + + /** Builds a doc with two milestones, each with their own root cells. */ + function buildTwoMilestoneDoc() { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: "Luke 1", + metadata: { type: CodexCellTypes.MILESTONE, id: "m1" }, + }, + ]; + for (let i = 1; i <= 5; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + cells.push({ + kind: 2, + languageId: "scripture", + value: "Luke 2", + metadata: { type: CodexCellTypes.MILESTONE, id: "m2" }, + }); + for (let i = 6; i <= 10; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + return cells; + } + + async function invokeHandler( + handlerName: string, + { document, content }: { document: CodexCellDocument; content: any; } + ): Promise { + const handler = __testOnlyMessageHandlers[handlerName]; + assert.ok(handler, `${handlerName} handler must be registered`); + await handler({ + event: { command: handlerName, content } as any, + document, + webviewPanel: {} as any, + provider, + updateWebview: () => { /* no-op */ }, + }); + } + + test("addMilestoneAtCell inserts a milestone and partitions placements", async () => { + const cells = buildMilestoneWithRoots(10); + // Pre-existing custom subdivisions: one before the new boundary + // (should stay on the original) and one after (should travel). + cells[0].metadata.data = { + subdivisions: [ + { startCellId: "v3", name: "Early" }, + { startCellId: "v8", name: "Late" }, + ], + }; + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeHandler("addMilestoneAtCell", { + document, + content: { milestoneIndex: 0, cellNumber: 6 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 2, "Should now have two milestones"); + // Original milestone: only v1..v5, with v3 placement preserved + assert.deepStrictEqual( + document.getRootContentCellIdsForMilestone(0), + ["v1", "v2", "v3", "v4", "v5"] + ); + const original = document.getCellByIndex(index.milestones[0].cellIndex); + const originalData = original?.metadata?.data as any; + assert.deepStrictEqual( + originalData?.subdivisions, + [{ startCellId: "v3", name: "Early" }] + ); + // New milestone: v6..v10, with v8 placement preserved + assert.deepStrictEqual( + document.getRootContentCellIdsForMilestone(1), + ["v6", "v7", "v8", "v9", "v10"] + ); + const inserted = document.getCellByIndex(index.milestones[1].cellIndex); + const insertedData = inserted?.metadata?.data as any; + assert.deepStrictEqual( + insertedData?.subdivisions, + [{ startCellId: "v8", name: "Late" }] + ); + assert.strictEqual( + inserted?.metadata?.type, + CodexCellTypes.MILESTONE + ); + }); + + test("addMilestoneAtCell rejects cellNumber < 2 as a no-op", async () => { + const document = await createDocumentWithCells(buildMilestoneWithRoots(10)); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + sinon.stub(vscode.window, "showWarningMessage"); + + await invokeHandler("addMilestoneAtCell", { + document, + content: { milestoneIndex: 0, cellNumber: 1 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 1, "No new milestone should be created"); + }); + + test("addMilestoneAtCell rejects writes from non-source documents", async () => { + const document = await createDocumentWithCells(buildMilestoneWithRoots(10)); + // Leave URI as the temp .codex file → handler must reject. + stubProviderForHandlerTest(provider); + const warnStub = sinon.stub(vscode.window, "showWarningMessage"); + + await invokeHandler("addMilestoneAtCell", { + document, + content: { milestoneIndex: 0, cellNumber: 6 }, + }); + + assert.strictEqual(warnStub.called, true, "Should surface a warning on target write"); + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 1, "Document must not be mutated"); + }); + + test("promoteSubdivisionToMilestone uses the subdivision's name as the new milestone label", async () => { + const cells = buildMilestoneWithRoots(10); + cells[0].metadata.data = { + subdivisions: [{ startCellId: "v6", name: "Section B" }], + }; + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeHandler("promoteSubdivisionToMilestone", { + document, + content: { milestoneIndex: 0, subdivisionKey: "v6" }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 2); + const inserted = document.getCellByIndex(index.milestones[1].cellIndex); + assert.strictEqual(inserted?.value, "Section B", "Promoted milestone takes the subdivision's name"); + // The promoted placement should NOT remain on the original + // milestone — it has been promoted. + const original = document.getCellByIndex(index.milestones[0].cellIndex); + const originalData = original?.metadata?.data as any; + assert.ok( + !originalData?.subdivisions || originalData.subdivisions.length === 0, + "Promoted placement should be removed from the original milestone" + ); + }); + + test("promoteSubdivisionToMilestone refuses the implicit first subdivision", async () => { + const document = await createDocumentWithCells(buildMilestoneWithRoots(10)); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + sinon.stub(vscode.window, "showWarningMessage"); + + await invokeHandler("promoteSubdivisionToMilestone", { + document, + content: { + milestoneIndex: 0, + subdivisionKey: FIRST_SUBDIVISION_KEY, + }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 1, "First subdivision is unpromotable"); + }); + + test("removeMilestone soft-deletes the milestone and lifts its subdivisions", async () => { + const cells = buildTwoMilestoneDoc(); + // Give the second milestone a custom subdivision on v8. + cells[6].metadata.data = { + subdivisions: [{ startCellId: "v8", name: "Tail" }], + }; + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeHandler("removeMilestone", { + document, + content: { milestoneIndex: 1 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 1, "Only the first milestone should remain"); + // Surviving milestone must absorb v6..v10 alongside v1..v5. + const survivor = document.getCellByIndex(index.milestones[0].cellIndex); + const data = survivor?.metadata?.data as any; + assert.deepStrictEqual( + data?.subdivisions, + [{ startCellId: "v8", name: "Tail" }], + "Removed milestone's placements lift onto the survivor" + ); + // No boundary placement at v6 — pure remove drops the seam. + assert.ok( + !data?.subdivisions?.some((p: any) => p.startCellId === "v6"), + "Pure remove should not preserve the boundary as a subdivision" + ); + }); + + test("removeMilestone refuses to remove the first milestone", async () => { + const document = await createDocumentWithCells(buildTwoMilestoneDoc()); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + sinon.stub(vscode.window, "showWarningMessage"); + + await invokeHandler("removeMilestone", { + document, + content: { milestoneIndex: 0 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 2, "First milestone must not be removable"); + }); + + test("demoteMilestoneToSubdivision preserves the boundary and carries the milestone label", async () => { + const document = await createDocumentWithCells(buildTwoMilestoneDoc()); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeHandler("demoteMilestoneToSubdivision", { + document, + content: { milestoneIndex: 1 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 1); + const survivor = document.getCellByIndex(index.milestones[0].cellIndex); + const data = survivor?.metadata?.data as any; + assert.deepStrictEqual( + data?.subdivisions, + [{ startCellId: "v6", name: "Luke 2" }], + "Demoted milestone's label becomes the boundary placement's name" + ); + }); + + test("demoteMilestoneToSubdivision refuses to demote the first milestone", async () => { + const document = await createDocumentWithCells(buildTwoMilestoneDoc()); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + sinon.stub(vscode.window, "showWarningMessage"); + + await invokeHandler("demoteMilestoneToSubdivision", { + document, + content: { milestoneIndex: 0 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 2, "First milestone must not be demotable"); + }); + }); }); diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx index 401eb3a24..44613bbe4 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx @@ -1602,4 +1602,216 @@ describe("MilestoneAccordion - Milestone Editing", () => { expect(refreshCalls).toHaveLength(0); }); }); + + // ---------------------------------------------------------------- + // Milestone-placement editing controls (gated by the workspace + // setting + isSourceText + settings mode). Hidden by default — when + // the controls are reachable they post the new structural commands + // (`addMilestoneAtCell`, `removeMilestone`, + // `promoteSubdivisionToMilestone`, `demoteMilestoneToSubdivision`). + // ---------------------------------------------------------------- + describe("Milestone Placement Editing", () => { + it("hides Add Milestone, demote, remove, and promote controls when the setting is off", () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: false, + getSubsectionsForMilestone: vi.fn(() => [ + { + id: "sub-0-1", + label: "1–5", + startIndex: 0, + endIndex: 5, + key: "__start__", + source: "auto", + } as Subsection, + { + id: "sub-0-2", + label: "6–10", + startIndex: 5, + endIndex: 10, + key: "v6", + startCellId: "v6", + source: "custom", + } as Subsection, + ]), + }); + + expect(screen.queryByLabelText("Add Milestone")).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Promote Subdivision to Milestone") + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Demote Milestone to Subdivision") + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Remove Milestone")).not.toBeInTheDocument(); + }); + + it("shows the Add Milestone button when the feature is on, source, in settings mode", () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + }); + expect(screen.getAllByLabelText("Add Milestone").length).toBeGreaterThan(0); + }); + + it("posts addMilestoneAtCell with the entered cell number", async () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + }); + + // Open the milestone form on the first milestone. + const openButtons = screen.getAllByLabelText("Add Milestone"); + await act(async () => { + fireEvent.click(openButtons[0]); + }); + + const input = screen.getByLabelText("Cell number for new milestone"); + await act(async () => { + fireEvent.change(input, { target: { value: "3" } }); + }); + + const submitButtons = screen.getAllByLabelText("Add Milestone"); + // After opening the form there are two "Add Milestone" buttons: + // the open trigger on other milestones + the submit button on + // this one. The submit one is type=submit. + const submit = submitButtons.find( + (b) => (b as HTMLButtonElement).type === "submit" + ) as HTMLButtonElement; + expect(submit).toBeTruthy(); + await act(async () => { + fireEvent.click(submit); + }); + + const calls = mockVscode.postMessage.mock.calls.filter( + (c: any[]) => c[0]?.command === "addMilestoneAtCell" + ); + expect(calls).toHaveLength(1); + expect(calls[0][0].content).toEqual({ milestoneIndex: 0, cellNumber: 3 }); + }); + + it("posts promoteSubdivisionToMilestone when the promote icon is clicked on a custom subdivision", async () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + getSubsectionsForMilestone: vi.fn(() => [ + { + id: "sub-0-1", + label: "1–5", + startIndex: 0, + endIndex: 5, + key: "__start__", + source: "auto", + } as Subsection, + { + id: "sub-0-2", + label: "6–10", + startIndex: 5, + endIndex: 10, + key: "v6", + startCellId: "v6", + source: "custom", + } as Subsection, + ]), + }); + + const promoteButton = screen.getAllByLabelText( + "Promote Subdivision to Milestone" + )[0]; + await act(async () => { + fireEvent.click(promoteButton); + }); + + const calls = mockVscode.postMessage.mock.calls.filter( + (c: any[]) => c[0]?.command === "promoteSubdivisionToMilestone" + ); + expect(calls).toHaveLength(1); + expect(calls[0][0].content.subdivisionKey).toBe("v6"); + }); + + it("first milestone never exposes remove or demote buttons", () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + milestoneIndex: createMockMilestoneIndex([ + { value: "Chapter 1", index: 0 }, + ]), + }); + expect(screen.queryByLabelText("Remove Milestone")).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Demote Milestone to Subdivision") + ).not.toBeInTheDocument(); + }); + + it("requires two clicks on Remove before posting removeMilestone", async () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + }); + + const removeButtons = screen.getAllByLabelText("Remove Milestone"); + // First click arms the action — no message yet. + await act(async () => { + fireEvent.click(removeButtons[0]); + }); + let calls = mockVscode.postMessage.mock.calls.filter( + (c: any[]) => c[0]?.command === "removeMilestone" + ); + expect(calls).toHaveLength(0); + + // Second click commits. + const armed = screen.getByLabelText("Confirm Remove Milestone"); + await act(async () => { + fireEvent.click(armed); + }); + calls = mockVscode.postMessage.mock.calls.filter( + (c: any[]) => c[0]?.command === "removeMilestone" + ); + expect(calls).toHaveLength(1); + expect(calls[0][0].content).toEqual({ milestoneIndex: 1 }); + }); + + it("requires two clicks on Demote before posting demoteMilestoneToSubdivision", async () => { + renderMilestoneAccordion({ + isSourceText: true, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + }); + + const demoteButtons = screen.getAllByLabelText( + "Demote Milestone to Subdivision" + ); + await act(async () => { + fireEvent.click(demoteButtons[0]); + }); + const armed = screen.getByLabelText("Confirm Demote Milestone"); + await act(async () => { + fireEvent.click(armed); + }); + const calls = mockVscode.postMessage.mock.calls.filter( + (c: any[]) => c[0]?.command === "demoteMilestoneToSubdivision" + ); + expect(calls).toHaveLength(1); + expect(calls[0][0].content).toEqual({ milestoneIndex: 1 }); + }); + + it("does not render placement controls on target documents", () => { + renderMilestoneAccordion({ + isSourceText: false, + initialSettingsMode: true, + enableMilestonePlacementEditing: true, + }); + expect(screen.queryByLabelText("Add Milestone")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Remove Milestone")).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Demote Milestone to Subdivision") + ).not.toBeInTheDocument(); + }); + }); }); From c397664e1c48044051bc308bbfc6ba0117a5b1d5 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Fri, 8 May 2026 17:09:52 -0500 Subject: [PATCH 24/34] Inline the milestone rename input on the milestone row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings milestone-rename UX to parity with the subdivision rename: the pencil now swaps the milestone label for an input on its own row (instead of swapping the dropdown header), and the matching save/cancel cluster takes over the row's pencil/destructive area. State changes: - `isEditingMilestone: boolean` becomes `editingMilestoneIdx: number | null` so we know WHICH row hosts the input (multiple milestones share the same accordion). - `handleMilestoneExpansion` no longer chases the user's expansion to re-target the editor — the input is anchored to the milestone it was opened on, just like subdivisions. - The header is now a static `

` + persistent gear button, so the settings toggle stays reachable while a rename is in flight (e.g. the user can still close the accordion mid-edit). The `Save Milestone Rename` / `Cancel Milestone Rename` aria-labels move with the buttons unchanged, so per-row scoped tests keep working. The starting-rename test is updated to reflect the inline anchor and the gear staying put. --- .../components/MilestoneAccordion.test.tsx | 13 +- .../components/MilestoneAccordion.tsx | 221 ++++++++---------- 2 files changed, 109 insertions(+), 125 deletions(-) diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx index 44613bbe4..7761205b0 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx @@ -213,7 +213,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { } describe("Milestone Rename - Starting", () => { - it("swaps the dropdown header from gear → save/cancel and reveals the rename input", async () => { + it("swaps the milestone row's pencil for a save/cancel cluster + inline input", async () => { renderMilestoneAccordion(); const renameButton = getRenameMilestoneButton(); @@ -223,18 +223,21 @@ describe("MilestoneAccordion - Milestone Editing", () => { fireEvent.click(renameButton); }); - // Header now hosts the rename input, prefilled with the milestone's - // current display value. + // The row now hosts the rename input, prefilled with the + // milestone's current display value (no longer in the dropdown + // header — the header keeps showing the static

+ gear). const input = screen.getByDisplayValue("Chapter 1"); expect(input).toBeInTheDocument(); expect(input.tagName).toBe("INPUT"); - // Save + Cancel replace the gear in the header during a rename. + // Save + Cancel replace the row's pencil/destructive cluster. expect(screen.getByLabelText("Save Milestone Rename")).toBeInTheDocument(); expect(screen.getByLabelText("Cancel Milestone Rename")).toBeInTheDocument(); + // Gear stays in the header during rename — inline editing is + // anchored to the row, not a header swap. expect( screen.queryByLabelText("Toggle Milestone Settings") - ).not.toBeInTheDocument(); + ).toBeInTheDocument(); }); it("should initialize input with current milestone value", async () => { diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx index e51d5bc3f..26e92e87e 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -120,10 +120,14 @@ export function MilestoneAccordion({ const [expandedMilestone, setExpandedMilestone] = useState( currentMilestoneIndex.toString() ); - const [isEditingMilestone, setIsEditingMilestone] = useState(false); + // Per-row milestone rename. The input lives inline on the milestone row + // (parity with the subsection rename pencil), so we track WHICH milestone + // index is in edit mode rather than a global boolean. `null` = not editing. + const [editingMilestoneIdx, setEditingMilestoneIdx] = useState(null); const [editedMilestoneValue, setEditedMilestoneValue] = useState(""); const [originalMilestoneValue, setOriginalMilestoneValue] = useState(""); const inputRef = useRef(null); + const isEditingMilestone = editingMilestoneIdx !== null; // Settings mode reveals destructive / structural controls (title pencil, // per-subsection pencils, add-break / reset footers). Default off so the // accordion stays read-only on first open; the gear button toggles it. @@ -624,7 +628,7 @@ export function MilestoneAccordion({ // Reset editing state when accordion closes useEffect(() => { if (!isOpen) { - setIsEditingMilestone(false); + setEditingMilestoneIdx(null); // Also collapse the gear/settings affordances so reopening the // accordion always starts from the read-only baseline (matches // initialSettingsMode default and avoids "stuck open" surprises). @@ -923,13 +927,10 @@ export function MilestoneAccordion({ 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); + setEditingMilestoneIdx(milestoneIdx); setTimeout(() => { inputRef.current?.focus(); @@ -937,65 +938,58 @@ export function MilestoneAccordion({ }, 0); }; - const handleSaveMilestone = (e: React.MouseEvent) => { + const handleSaveMilestone = (e: React.MouseEvent | React.KeyboardEvent) => { e.stopPropagation(); - const displayedIndex = getDisplayedMilestoneIndex(); + const targetIdx = editingMilestoneIdx; + if (targetIdx === null) return; const trimmedValue = editedMilestoneValue.trim(); - const displayedMilestone = getDisplayedMilestone(); + const targetMilestone = milestoneIndex?.milestones[targetIdx]; if ( - displayedMilestone && + targetMilestone && trimmedValue !== "" && - trimmedValue !== displayedMilestone.value + trimmedValue !== targetMilestone.value ) { - // Validate that the milestone index is still valid before sending - if (displayedIndex < 0 || displayedIndex >= (milestoneIndex?.milestones.length || 0)) { + if (targetIdx < 0 || targetIdx >= (milestoneIndex?.milestones.length || 0)) { console.error( - `[handleSaveMilestone] Invalid milestone index: ${displayedIndex}, total milestones: ${ + `[handleSaveMilestone] Invalid milestone index: ${targetIdx}, total milestones: ${ milestoneIndex?.milestones.length || 0 }` ); return; } - // Send message to update milestone value; provider pushes updated data to webview immediately vscode.postMessage({ command: "updateMilestoneValue", content: { - milestoneIndex: displayedIndex, + milestoneIndex: targetIdx, newValue: trimmedValue, }, }); - // Update the original value to the new saved value so the checkmark state is correct setOriginalMilestoneValue(trimmedValue); - // Update local cache immediately so the accordion shows the change before webview refresh setLocalMilestoneValues((prev) => ({ ...prev, - [displayedIndex]: trimmedValue, + [targetIdx]: trimmedValue, })); } - // Keep the accordion open and exit edit mode to show the saved result - setIsEditingMilestone(false); + setEditingMilestoneIdx(null); }; - const handleRevertMilestone = (e: React.MouseEvent) => { + const handleRevertMilestone = (e: React.MouseEvent | React.KeyboardEvent) => { e.stopPropagation(); - // Revert the value and close the edit field setEditedMilestoneValue(originalMilestoneValue); - setIsEditingMilestone(false); + setEditingMilestoneIdx(null); }; const handleInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); - handleSaveMilestone(e as any); + handleSaveMilestone(e); } else if (e.key === "Escape") { e.preventDefault(); - // Escape should exit edit mode and revert - setEditedMilestoneValue(originalMilestoneValue); - setIsEditingMilestone(false); + handleRevertMilestone(e); } }; @@ -1060,31 +1054,11 @@ export function MilestoneAccordion({ } }; - // Handle milestone expansion - if editing, switch to editing the new milestone + // Handle milestone expansion. Rename now lives inline on each row so we no + // longer need to follow the user's selection — the input stays anchored to + // the milestone it was opened on. const handleMilestoneExpansion = (value: string | null) => { - // Update expanded milestone first setExpandedMilestone(value); - - if (isEditingMilestone && value !== null) { - // User clicked on another milestone while editing - switch to editing that milestone - const newMilestoneIndex = parseInt(value); - if (!isNaN(newMilestoneIndex) && milestoneIndex?.milestones[newMilestoneIndex]) { - // Use getDisplayedMilestone to get the value (which includes local cache) - const displayedIndex = newMilestoneIndex; - const milestone = milestoneIndex.milestones[displayedIndex]; - if (milestone) { - // Use cached value if available, otherwise use prop value - const displayValue = localMilestoneValues[displayedIndex] || milestone.value; - setOriginalMilestoneValue(displayValue); - setEditedMilestoneValue(displayValue); - // Keep edit mode open and focus the input - setTimeout(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, 0); - } - } - } }; return ( @@ -1112,73 +1086,32 @@ export function MilestoneAccordion({ }} >
- {isEditingMilestone ? ( - setEditedMilestoneValue(e.target.value)} - onKeyDown={handleInputKeyDown} - className="text-lg font-semibold m-0 bg-transparent border border-[var(--vscode-input-border)] rounded px-2 py-1 flex-1 mr-2 focus:outline-none focus:ring-2 focus:ring-[var(--vscode-focusBorder)]" - style={{ - color: "var(--vscode-input-foreground)", - }} - /> - ) : ( -

{getDisplayedMilestoneValue()}

- )} +

{getDisplayedMilestoneValue()}

- {isEditingMilestone ? ( - <> - - - - - - - - ) : ( - <> - { - e.stopPropagation(); - setIsSettingsMode((prev) => !prev); - }} - aria-pressed={isSettingsMode} - > - - - - )} + { + e.stopPropagation(); + setIsSettingsMode((prev) => !prev); + }} + aria-pressed={isSettingsMode} + > + +
= 100; + const isEditingThisMilestone = + editingMilestoneIdx === milestoneIdx; + return (
- - {displayValue} - + {isEditingThisMilestone ? ( + + setEditedMilestoneValue( + e.target.value + ) + } + onKeyDown={handleInputKeyDown} + onClick={(e) => e.stopPropagation()} + className="font-medium flex-1 mr-2 bg-transparent border border-[var(--vscode-input-border)] rounded px-2 py-0.5 focus:outline-none focus:ring-2 focus:ring-[var(--vscode-focusBorder)]" + style={{ + color: "var(--vscode-input-foreground)", + }} + /> + ) : ( + + {displayValue} + + )}
- {isSettingsMode && ( + {isEditingThisMilestone ? ( + <> + + + + + + + + ) : ( + isSettingsMode && ( <> )} + ) )}
Date: Fri, 8 May 2026 17:11:17 -0500 Subject: [PATCH 25/34] Commit demote on a single click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demote is reversible — promote turns the resulting subdivision break right back into a milestone — so the two-click arm/confirm step was unnecessary friction. Drops the `demoteConfirm*` state and timer, the arming aria-label/title swap, and the matching armed-styling on the button. Pure remove keeps its two-click confirmation since it drops the seam entirely. Test now asserts a single click commits and that the "Confirm Demote Milestone" arming label never appears. --- .../components/MilestoneAccordion.test.tsx | 11 ++-- .../components/MilestoneAccordion.tsx | 54 +++++-------------- 2 files changed, 19 insertions(+), 46 deletions(-) diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx index 7761205b0..919f43206 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx @@ -1780,7 +1780,7 @@ describe("MilestoneAccordion - Milestone Editing", () => { expect(calls[0][0].content).toEqual({ milestoneIndex: 1 }); }); - it("requires two clicks on Demote before posting demoteMilestoneToSubdivision", async () => { + it("posts demoteMilestoneToSubdivision on a single click", async () => { renderMilestoneAccordion({ isSourceText: true, initialSettingsMode: true, @@ -1793,15 +1793,16 @@ describe("MilestoneAccordion - Milestone Editing", () => { await act(async () => { fireEvent.click(demoteButtons[0]); }); - const armed = screen.getByLabelText("Confirm Demote Milestone"); - await act(async () => { - fireEvent.click(armed); - }); const calls = mockVscode.postMessage.mock.calls.filter( (c: any[]) => c[0]?.command === "demoteMilestoneToSubdivision" ); expect(calls).toHaveLength(1); expect(calls[0][0].content).toEqual({ milestoneIndex: 1 }); + // Confirm there's no two-click "Confirm Demote Milestone" arming + // step left over from the previous behavior. + expect( + screen.queryByLabelText("Confirm Demote Milestone") + ).not.toBeInTheDocument(); }); it("does not render placement controls on target documents", () => { diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx index 26e92e87e..2e6fa5c3f 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -179,20 +179,15 @@ export function MilestoneAccordion({ const [addMilestoneError, setAddMilestoneError] = useState(""); const addMilestoneInputRef = useRef(null); - // Two-click confirmation for the milestone trash + demote actions. Same - // shape as `resetConfirmMilestoneIdx` so we can share the timer logic. + // Two-click confirmation for the milestone trash. Demote is reversible + // (you can promote back) so it commits on a single click; remove drops + // the seam entirely, so it stays gated on the arm-then-confirm pattern. const [removeConfirmMilestoneIdx, setRemoveConfirmMilestoneIdx] = useState(null); const removeConfirmTimeoutRef = useRef(null); - const [demoteConfirmMilestoneIdx, setDemoteConfirmMilestoneIdx] = useState(null); - const demoteConfirmTimeoutRef = useRef(null); useEffect(() => { - // Snapshot the refs at effect-entry so the cleanup closure doesn't - // capture stale `.current` values across rerenders (and so eslint's - // exhaustive-deps doesn't flag them). const resetTimer = resetConfirmTimeoutRef; const removeTimer = removeConfirmTimeoutRef; - const demoteTimer = demoteConfirmTimeoutRef; return () => { if (resetTimer.current !== null) { window.clearTimeout(resetTimer.current); @@ -200,9 +195,6 @@ export function MilestoneAccordion({ if (removeTimer.current !== null) { window.clearTimeout(removeTimer.current); } - if (demoteTimer.current !== null) { - window.clearTimeout(demoteTimer.current); - } }; }, []); @@ -493,18 +485,14 @@ export function MilestoneAccordion({ if (!isSourceText) return; if (!enableMilestonePlacementEditing) return; if (milestoneIdx === 0) return; - armOrCommit( - milestoneIdx, - demoteConfirmMilestoneIdx, - setDemoteConfirmMilestoneIdx, - demoteConfirmTimeoutRef, - () => { - vscode.postMessage({ - command: "demoteMilestoneToSubdivision", - content: { milestoneIndex: milestoneIdx }, - }); - } - ); + // Demote commits on a single click — it's reversible (promote back to + // a milestone) and only flips an existing milestone's role to a + // subdivision break. Pure remove keeps the two-click confirmation + // since it drops the seam entirely. + vscode.postMessage({ + command: "demoteMilestoneToSubdivision", + content: { milestoneIndex: milestoneIdx }, + }); }; // Calculate position and dimensions @@ -1283,31 +1271,15 @@ export function MilestoneAccordion({ milestoneIdx > 0 ? ( <> handleDemoteMilestoneClick( e, milestoneIdx ) } - className={ - demoteConfirmMilestoneIdx === - milestoneIdx - ? "bg-inputValidation-warningBackground" - : undefined - } > From 21f2973d5a99d48a34e649265f99db0ab011c777 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Fri, 8 May 2026 17:13:42 -0500 Subject: [PATCH 26/34] Default new milestones to a 'New milestone' placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When neither the caller nor the boundary subdivision provides a label (typical for `addMilestoneAtCell`, or for promoting an unnamed subdivision break), `commitSplitMilestoneAtAnchor` was falling through to `buildMilestoneCellPayload`'s chapter-style auto-label — which clones the parent milestone's name (e.g. two "Luke 1"s side by side). Switch the cascade to: explicit caller override → boundary placement name → subdivisionNames map entry → "New milestone" placeholder. Centralises the placeholder as a `NEW_MILESTONE_DEFAULT_LABEL` constant. Adds two Mocha cases: addMilestoneAtCell stamping the placeholder, and promoteSubdivisionToMilestone using it when the underlying subdivision break is unnamed. --- .../codexCellEditorMessagehandling.ts | 29 +++++++++--- src/test/suite/milestoneSubdivisions.test.ts | 45 +++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 6b35e0d5b..7b78a60f6 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -206,6 +206,18 @@ export async function sendMilestoneRefreshToWebview( } } +/** + * Default label stamped on a milestone created from `addMilestoneAtCell` or + * `promoteSubdivisionToMilestone` when no other name is available (the + * boundary subdivision is unnamed and the caller didn't pass an override). + * + * We deliberately don't fall back to the chapter-style auto-label here — + * that would duplicate the parent milestone's name (e.g. two "Luke 1"s), + * which is the duplication users complain about. The placeholder makes it + * obvious the milestone needs a name. + */ +const NEW_MILESTONE_DEFAULT_LABEL = "New milestone"; + /** * Shared worker for all handlers that need to persist a new placement list * onto a source milestone. Performs validation (source-only, milestone must @@ -861,15 +873,18 @@ async function commitSplitMilestoneAtAnchor({ moveKeys ); - // The label takes precedence on the milestone row. Fall back to the - // boundary placement's stored name if no explicit override was provided - // (typical when promoting a named subdivision via the dedicated path — - // the caller passes `newMilestoneLabel` directly. For ADD-AT-CELL the - // boundary usually has no name and we let `buildMilestoneCellPayload` - // derive a chapter-style default). + // The label takes precedence on the milestone row. Cascade: + // 1. Explicit override from the caller (promote-named-subdivision). + // 2. Boundary placement's inline name. + // 3. Subdivision-names override at the boundary key. + // 4. Generic "New milestone" placeholder. + // We deliberately do NOT fall back to the chapter-style default here: + // that would clone the parent milestone's label (e.g. two "Luke 1"s), + // which is exactly the duplication the user has to rename away from. const fallbackBoundaryName = split.boundaryName ?? sourceNamePartition.moved[FIRST_SUBDIVISION_KEY]; - const valueOverride = newMilestoneLabel || fallbackBoundaryName; + const valueOverride = + newMilestoneLabel || fallbackBoundaryName || NEW_MILESTONE_DEFAULT_LABEL; // Newly-created milestone gets its own subdivisions/subdivisionNames // populated atomically in the same `insertMilestoneCell` call, sparing diff --git a/src/test/suite/milestoneSubdivisions.test.ts b/src/test/suite/milestoneSubdivisions.test.ts index b3dcfb89b..0f7f2d81b 100644 --- a/src/test/suite/milestoneSubdivisions.test.ts +++ b/src/test/suite/milestoneSubdivisions.test.ts @@ -1397,6 +1397,51 @@ suite("Milestone Subdivisions Test Suite", () => { assert.strictEqual(index.milestones.length, 1, "First subdivision is unpromotable"); }); + test("promoteSubdivisionToMilestone falls back to 'New milestone' when the subdivision is unnamed", async () => { + const cells = buildMilestoneWithRoots(10); + // Custom subdivision at v6 with NO `name` — i.e. just a break. + cells[0].metadata.data = { + subdivisions: [{ startCellId: "v6" }], + }; + const document = await createDocumentWithCells(cells); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeHandler("promoteSubdivisionToMilestone", { + document, + content: { milestoneIndex: 0, subdivisionKey: "v6" }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 2); + const inserted = document.getCellByIndex(index.milestones[1].cellIndex); + assert.strictEqual( + inserted?.value, + "New milestone", + "Unnamed promotions should not duplicate the parent milestone's chapter label" + ); + }); + + test("addMilestoneAtCell stamps 'New milestone' as the placeholder label", async () => { + const document = await createDocumentWithCells(buildMilestoneWithRoots(10)); + stampSourceUri(document); + stubProviderForHandlerTest(provider); + + await invokeHandler("addMilestoneAtCell", { + document, + content: { milestoneIndex: 0, cellNumber: 6 }, + }); + + const index = document.buildMilestoneIndex(50); + assert.strictEqual(index.milestones.length, 2); + const inserted = document.getCellByIndex(index.milestones[1].cellIndex); + assert.strictEqual( + inserted?.value, + "New milestone", + "New milestones should default to a placeholder, not the parent chapter label" + ); + }); + test("removeMilestone soft-deletes the milestone and lifts its subdivisions", async () => { const cells = buildTwoMilestoneDoc(); // Give the second milestone a custom subdivision on v8. From a0d02c91177914c1213f0e396266f42a78277375 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Fri, 8 May 2026 17:20:33 -0500 Subject: [PATCH 27/34] Refresh source + target webviews before persisting structural edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The promote/demote/add/remove handlers were awaiting two slow steps (saveCustomDocument + updateCellMilestoneIndices, on both source and target) BEFORE the webview refresh, so the user saw the new milestone structure only after a full disk write + SQLite FTS reflush per document. With both sides involved this could be 500ms–1s of dead time per click — visible enough that the user reported it as "delay". Reorder both `commitSplitMilestoneAtAnchor` and `commitMergeMilestoneIntoPrevious` to: 1. Apply the in-memory mutations on source and target. 2. Adjust cached cursor positions on both URIs. 3. Push `sendMilestoneRefreshToWebview` to source AND target panels (the milestone index they read is rebuilt from in-memory state, so it's already correct at this point). 4. Run `saveCustomDocument` + `updateCellMilestoneIndices` in parallel for source AND target via `Promise.all` AFTER the UI is updated. If a save fails the user gets a notification — the in-memory state already reflects the change (so subsequent edits compose correctly), matching the prior behavior on partial-save errors. Existing handler tests stub both persistence calls and assert on document state, so they keep passing under the new ordering. --- .../codexCellEditorMessagehandling.ts | 66 +++++++++++++++---- 1 file changed, 55 insertions(+), 11 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 7b78a60f6..416094c56 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -633,6 +633,8 @@ async function commitMergeMilestoneIntoPrevious({ const cancellationToken = new vscode.CancellationTokenSource().token; + // In-memory source mutation only. Persistence + FTS reflush happen at + // the end so the source webview sees the merge before disk I/O finishes. try { await document.refreshAuthor(); document.softDeleteCell(removedMilestoneCellId); @@ -640,11 +642,6 @@ async function commitMergeMilestoneIntoPrevious({ subdivisions: merged.placements, subdivisionNames: mergedSourceNames, }); - await provider.saveCustomDocument(document, cancellationToken); - // Reflush per-cell milestoneIndex on the source. Cell count is - // unchanged after a soft-delete, so the optimistic short-circuit in - // updateCellMilestoneIndices would otherwise skip this update. - await document.updateCellMilestoneIndices({ force: true }); } catch (error) { console.error(`${logPrefix} Failed to update source:`, error); vscode.window.showErrorMessage( @@ -705,8 +702,6 @@ async function commitMergeMilestoneIntoPrevious({ subdivisions: merged.placements, subdivisionNamesFromSource: mergedSourceNames, }); - await provider.saveCustomDocument(targetDocument, cancellationToken); - await targetDocument.updateCellMilestoneIndices({ force: true }); mirroredTargetDocument = targetDocument; } } @@ -759,6 +754,30 @@ async function commitMergeMilestoneIntoPrevious({ } } } + + // Persist source + target + FTS rebuilds in parallel AFTER the webviews + // have already been told about the merge. See the matching block in + // `commitSplitMilestoneAtAnchor` for the rationale. + const persistenceWork: Promise[] = [ + provider.saveCustomDocument(document, cancellationToken), + document.updateCellMilestoneIndices({ force: true }), + ]; + if (mirroredTargetDocument) { + persistenceWork.push( + provider.saveCustomDocument(mirroredTargetDocument, cancellationToken), + mirroredTargetDocument.updateCellMilestoneIndices({ force: true }), + ); + } + try { + await Promise.all(persistenceWork); + } catch (persistError) { + console.error(`${logPrefix} Persistence failed (UI already updated):`, persistError); + vscode.window.showErrorMessage( + `${errorPrefix}: failed to persist changes — ${ + persistError instanceof Error ? persistError.message : String(persistError) + }` + ); + } } /** @@ -900,6 +919,10 @@ async function commitSplitMilestoneAtAnchor({ const cancellationToken = new vscode.CancellationTokenSource().token; let insertedMilestoneCellId: string; + // In-memory source mutation only; persistence (saveCustomDocument + + // updateCellMilestoneIndices) is hoisted to the bottom of the function so + // the webview refresh fires before disk I/O completes — keeping + // promote/demote/add/remove visually instant. try { await document.refreshAuthor(); const inserted = document.insertMilestoneCell({ @@ -912,8 +935,6 @@ async function commitSplitMilestoneAtAnchor({ subdivisions: split.before, subdivisionNames: sourceNamePartition.kept, }); - await provider.saveCustomDocument(document, cancellationToken); - await document.updateCellMilestoneIndices({ force: true }); } catch (error) { console.error(`${logPrefix} Failed to update source:`, error); vscode.window.showErrorMessage( @@ -1025,8 +1046,6 @@ async function commitSplitMilestoneAtAnchor({ subdivisionNames: targetNamePartition.kept, subdivisionNamesFromSource: mergedSourceNamesForOriginal, }); - await provider.saveCustomDocument(targetDocument, cancellationToken); - await targetDocument.updateCellMilestoneIndices({ force: true }); mirroredTargetDocument = targetDocument; } } @@ -1079,6 +1098,31 @@ async function commitSplitMilestoneAtAnchor({ } } } + + // Persist source + target + FTS rebuilds in parallel AFTER the webviews + // have already been told about the new structure. The in-memory state + // is the source of truth for the refresh, so disk I/O latency no longer + // gates the user's perceived completion of promote/demote/add/remove. + const persistenceWork: Promise[] = [ + provider.saveCustomDocument(document, cancellationToken), + document.updateCellMilestoneIndices({ force: true }), + ]; + if (mirroredTargetDocument) { + persistenceWork.push( + provider.saveCustomDocument(mirroredTargetDocument, cancellationToken), + mirroredTargetDocument.updateCellMilestoneIndices({ force: true }), + ); + } + try { + await Promise.all(persistenceWork); + } catch (persistError) { + console.error(`${logPrefix} Persistence failed (UI already updated):`, persistError); + vscode.window.showErrorMessage( + `${errorPrefix}: failed to persist changes — ${ + persistError instanceof Error ? persistError.message : String(persistError) + }` + ); + } } /** From 6cd2a100fee1247f3ecb94d40f945a2173b4efc7 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Fri, 8 May 2026 17:57:50 -0500 Subject: [PATCH 28/34] Mirror milestone renames from source to target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structural mirror already stamps the source's label on the target when a milestone is added/promoted, but subsequent renames stayed local to the source. Now `updateMilestoneValue` propagates the new label to the paired target's `cell.value` whenever the rename originates on a source file, gated by matching milestone cell IDs and root cell IDs so diverged structures are left alone. Target-side renames remain local; placements stay source-authoritative. Also reorders the handler's persistence step to mirror the snappy pattern used by the structural helpers: in-memory mutate both docs, refresh both webviews, then save in parallel — so the rename appears without waiting on disk I/O. --- .../codexCellEditorMessagehandling.ts | 153 +++++++++++--- src/test/suite/milestoneSubdivisions.test.ts | 199 ++++++++++++++++++ 2 files changed, 327 insertions(+), 25 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 416094c56..f924775ad 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -2151,6 +2151,8 @@ const messageHandlers: Record Promise Promise Promise[] = [ + provider.saveCustomDocument(document, cancellationToken), + ]; + if (mirroredTargetDocument) { + persistenceWork.push( + provider.saveCustomDocument(mirroredTargetDocument, cancellationToken) + ); + } + try { + await Promise.all(persistenceWork); + debug( + `[updateMilestoneValue] Saved rename for milestone ${typedEvent.content.milestoneIndex} (mirrored=${mirroredTargetDocument ? "yes" : "no"})` + ); + } catch (saveError) { + console.error( + `[updateMilestoneValue] Failed to save file ${document.uri.fsPath}:`, + saveError + ); + vscode.window.showErrorMessage( + `Failed to save milestone update: ${ + saveError instanceof Error ? saveError.message : String(saveError) + }` + ); + } }, refreshWebviewAfterMilestoneEdits: async ({ document, webviewPanel, provider }) => { diff --git a/src/test/suite/milestoneSubdivisions.test.ts b/src/test/suite/milestoneSubdivisions.test.ts index 0f7f2d81b..cd97df4e5 100644 --- a/src/test/suite/milestoneSubdivisions.test.ts +++ b/src/test/suite/milestoneSubdivisions.test.ts @@ -1525,4 +1525,203 @@ suite("Milestone Subdivisions Test Suite", () => { assert.strictEqual(index.milestones.length, 2, "First milestone must not be demotable"); }); }); + + // --------------------------------------------------------------------------- + // Milestone rename mirror: renaming a milestone on the source should + // overwrite the paired target's `cell.value` as-is (no override pattern). + // Renaming on the target should leave the source untouched. + // --------------------------------------------------------------------------- + + suite("Milestone rename mirror (source -> target)", () => { + let pairedTempUri: vscode.Uri | undefined; + + teardown(async () => { + if (pairedTempUri) { + await deleteIfExists(pairedTempUri); + pairedTempUri = undefined; + } + }); + + function buildSingleMilestoneCells(milestoneId: string, milestoneValue: string) { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: milestoneValue, + metadata: { type: CodexCellTypes.MILESTONE, id: milestoneId }, + }, + ]; + for (let i = 1; i <= 3; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + return cells; + } + + async function createPairedTargetDocument(cells: any[]): Promise<{ + uri: vscode.Uri; + document: CodexCellDocument; + }> { + const targetUri = await createTempCodexFile( + `test-target-${Date.now()}-${Math.random().toString(36).slice(2)}.codex`, + { cells, metadata: {} } + ); + pairedTempUri = targetUri; + const targetDocument = await provider.openCustomDocument( + targetUri, + { backupId: undefined }, + new vscode.CancellationTokenSource().token + ); + return { uri: targetUri, document: targetDocument }; + } + + function stampSourceUri(document: CodexCellDocument) { + (document as any).uri = vscode.Uri.parse("file:///test.source"); + } + + async function invokeHandler( + handlerName: string, + { document, content }: { document: CodexCellDocument; content: any; } + ): Promise { + const handler = __testOnlyMessageHandlers[handlerName]; + assert.ok(handler, `${handlerName} handler must be registered`); + await handler({ + event: { command: handlerName, content } as any, + document, + webviewPanel: {} as any, + provider, + updateWebview: () => { /* no-op */ }, + }); + } + + function stubProviderForRenameTest(p: CodexCellEditorProvider, targetDoc?: CodexCellDocument, targetUri?: vscode.Uri) { + sinon.stub(p, "saveCustomDocument").resolves(); + sinon.stub(p, "refreshWebview").resolves(); + sinon.stub(p, "getWebviewPanelForUri").returns(undefined); + (p as any).currentMilestoneSubsectionMap = new Map(); + sinon.stub(CodexCellDocument.prototype as any, "refreshAuthor").resolves(); + sinon.stub( + CodexCellDocument.prototype as any, + "updateCellMilestoneIndices" + ).resolves(); + if (targetDoc && targetUri) { + sinon.stub(p, "getPairedNotebookUri").returns(targetUri); + sinon.stub(p, "getOrOpenDocumentForUri").resolves(targetDoc); + } else { + sinon.stub(p, "getPairedNotebookUri").returns(null); + } + } + + test("source rename overwrites the paired target's milestone value", async () => { + const sourceCells = buildSingleMilestoneCells("ms-1", "Luke 1"); + const targetCells = buildSingleMilestoneCells("ms-1", "Luke 1"); + const sourceDoc = await createDocumentWithCells(sourceCells); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument(targetCells); + stubProviderForRenameTest(provider, targetDoc, targetUri); + + await invokeHandler("updateMilestoneValue", { + document: sourceDoc, + content: { milestoneIndex: 0, newValue: "Section A" }, + }); + + const sourceIndex = sourceDoc.buildMilestoneIndex(50); + const sourceCell = sourceDoc.getCellByIndex(sourceIndex.milestones[0].cellIndex); + assert.strictEqual(sourceCell?.value, "Section A", "Source should be renamed"); + + const targetIndex = targetDoc.buildMilestoneIndex(50); + const targetCell = targetDoc.getCellByIndex(targetIndex.milestones[0].cellIndex); + assert.strictEqual( + targetCell?.value, + "Section A", + "Target should mirror the source's new label as-is" + ); + }); + + test("source rename overwrites a target-side rename (no override stickiness)", async () => { + // Target user has already renamed the milestone locally to "Lucas 1". + // The user's clarification was: "just bring it over and name it as-is. + // The user can change it later." → next source rename wins. + const sourceCells = buildSingleMilestoneCells("ms-1", "Luke 1"); + const targetCells = buildSingleMilestoneCells("ms-1", "Lucas 1"); + const sourceDoc = await createDocumentWithCells(sourceCells); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument(targetCells); + stubProviderForRenameTest(provider, targetDoc, targetUri); + + await invokeHandler("updateMilestoneValue", { + document: sourceDoc, + content: { milestoneIndex: 0, newValue: "Section A" }, + }); + + const targetIndex = targetDoc.buildMilestoneIndex(50); + const targetCell = targetDoc.getCellByIndex(targetIndex.milestones[0].cellIndex); + assert.strictEqual( + targetCell?.value, + "Section A", + "Source rename overwrites target's local label" + ); + }); + + test("target rename does NOT mirror back to the source", async () => { + // The target document is being renamed directly. Source must stay + // untouched — placements remain source-authoritative. + const sourceCells = buildSingleMilestoneCells("ms-1", "Luke 1"); + const targetCells = buildSingleMilestoneCells("ms-1", "Luke 1"); + const sourceDoc = await createDocumentWithCells(sourceCells); + // Leave sourceDoc URI as the temp .codex; we only need it to be a + // separate doc to verify it isn't touched. + const { document: targetDoc } = await createPairedTargetDocument(targetCells); + // Intentionally do NOT pass sourceDoc as paired; the handler should + // detect it's a target rename via `isSourceFileFlexible` and skip + // the mirror entirely. + stubProviderForRenameTest(provider); + + await invokeHandler("updateMilestoneValue", { + document: targetDoc, + content: { milestoneIndex: 0, newValue: "Lucas 1" }, + }); + + const targetIndex = targetDoc.buildMilestoneIndex(50); + const targetCell = targetDoc.getCellByIndex(targetIndex.milestones[0].cellIndex); + assert.strictEqual(targetCell?.value, "Lucas 1", "Target rename applies locally"); + + const sourceIndex = sourceDoc.buildMilestoneIndex(50); + const sourceCell = sourceDoc.getCellByIndex(sourceIndex.milestones[0].cellIndex); + assert.strictEqual( + sourceCell?.value, + "Luke 1", + "Source must not be mutated by a target-side rename" + ); + }); + + test("mirror is skipped when the target's milestone cell ID has diverged", async () => { + // Same root cell IDs, but the milestone cell itself has a different + // ID on the target — e.g. the project drifted apart structurally. + // We must NOT overwrite an unrelated milestone's label. + const sourceCells = buildSingleMilestoneCells("ms-source-1", "Luke 1"); + const targetCells = buildSingleMilestoneCells("ms-target-1", "Luke 1"); + const sourceDoc = await createDocumentWithCells(sourceCells); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument(targetCells); + stubProviderForRenameTest(provider, targetDoc, targetUri); + + await invokeHandler("updateMilestoneValue", { + document: sourceDoc, + content: { milestoneIndex: 0, newValue: "Section A" }, + }); + + const targetIndex = targetDoc.buildMilestoneIndex(50); + const targetCell = targetDoc.getCellByIndex(targetIndex.milestones[0].cellIndex); + assert.strictEqual( + targetCell?.value, + "Luke 1", + "Target must not be touched when milestone IDs diverge" + ); + }); + }); }); From 9dfea29e50c23248538d68d4502eb93d609218bc Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Fri, 8 May 2026 18:02:24 -0500 Subject: [PATCH 29/34] Refresh subdivision-mirror paths before disk save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same in-memory-mutate / refresh-then-persist ordering already used by the structural milestone helpers to the two remaining mirror paths: `commitMilestoneSubdivisionPlacements` (subdivision break add/remove + the promotion path of subdivision rename) and the non- promotion path of `updateMilestoneSubdivisionName`. Both webview panels now see the new placements / `subdivisionNamesFromSource` immediately, and the source + target saves run in parallel after the UI has already updated — eliminating the disk-I/O wait the user could perceive on slower filesystems. --- .../codexCellEditorMessagehandling.ts | 57 +++++++++++++++++-- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index f924775ad..b812ef7ca 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -308,10 +308,13 @@ async function commitMilestoneSubdivisionPlacements({ const sourceCellId = sourceMilestoneCell.metadata.id; const cancellationToken = new vscode.CancellationTokenSource().token; + // In-memory source mutation only; persistence is hoisted to the bottom + // so the webview refresh fires before disk I/O completes — keeping the + // subdivision break edit visually instant. See the matching block in + // `commitSplitMilestoneAtAnchor` for the rationale. try { await document.refreshAuthor(); document.updateCellData(sourceCellId, { subdivisions: sanitized }); - await provider.saveCustomDocument(document, cancellationToken); } catch (error) { console.error(`${logPrefix} Failed to update source:`, error); vscode.window.showErrorMessage( @@ -396,7 +399,6 @@ async function commitMilestoneSubdivisionPlacements({ subdivisions: mirroredPlacements, subdivisionNamesFromSource: mirroredSourceNames, }); - await provider.saveCustomDocument(targetDocument, cancellationToken); mirroredTargetDocument = targetDocument; } } @@ -433,6 +435,29 @@ async function commitMilestoneSubdivisionPlacements({ } } } + + // Persist source + target in parallel AFTER the webviews have been told + // about the new placements. Mirrors the snappy ordering used by the + // structural milestone helpers — disk I/O latency no longer gates the + // user's perceived completion of subdivision break edits. + const persistenceWork: Promise[] = [ + provider.saveCustomDocument(document, cancellationToken), + ]; + if (mirroredTargetDocument) { + persistenceWork.push( + provider.saveCustomDocument(mirroredTargetDocument, cancellationToken) + ); + } + try { + await Promise.all(persistenceWork); + } catch (persistError) { + console.error(`${logPrefix} Persistence failed (UI already updated):`, persistError); + vscode.window.showErrorMessage( + `${errorPrefix}: failed to persist changes — ${ + persistError instanceof Error ? persistError.message : String(persistError) + }` + ); + } } /** @@ -2581,10 +2606,11 @@ const messageHandlers: Record Promise Promise Promise[] = [ + provider.saveCustomDocument(document, cancellationToken), + ]; + if (mirroredTargetDocument) { + persistenceWork.push( + provider.saveCustomDocument(mirroredTargetDocument, cancellationToken) + ); + } + try { + await Promise.all(persistenceWork); + } catch (persistError) { + console.error( + "[updateMilestoneSubdivisionName] Persistence failed (UI already updated):", + persistError + ); + vscode.window.showErrorMessage( + `Failed to save subdivision rename: ${ + persistError instanceof Error ? persistError.message : String(persistError) + }` + ); + } }, addMilestoneAtCell: async ({ event, document, webviewPanel, provider }) => { From 070d94258c5aac3096e6038c6b7cbe1db5d69c81 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Fri, 15 May 2026 21:31:28 -0500 Subject: [PATCH 30/34] Force-apply server-pushed milestone refreshes in the webview The CodexCellEditor stale guard rejected any providerSendsInitialContentPaginated whose currentMilestoneIndex / currentSubsectionIndex did not match the webview's refs. That guard is meant to keep an in-flight initial-content message from clobbering user navigation, but it also rejected every refresh the provider pushed after a structural milestone edit shifted the cursor (e.g. demote moves the viewer from milestone N to N-1). The accordion stayed frozen on the pre-edit structure and refreshCurrentPage re-requested cells from the now-stale ref position. Add a force flag to providerSendsInitialContentPaginated and refreshCurrentPage; sendMilestoneRefreshToWebview sets it on every push since by construction it only fires after the provider has mutated the document. The webview bypasses the stale guard, drops any in-flight navigation request, and realigns its refs to the message's position so the follow-up cell request hits the new range. --- .../codexCellEditorMessagehandling.ts | 7 ++ types/index.d.ts | 18 +++++ .../src/CodexCellEditor/CodexCellEditor.tsx | 41 ++++++++-- .../initialContentStaleGuard.test.tsx | 77 ++++++++++++++++++- .../hooks/useVSCodeMessageHandler.ts | 6 +- 5 files changed, 138 insertions(+), 11 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index b812ef7ca..3cab15bbf 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -178,6 +178,11 @@ export async function sendMilestoneRefreshToWebview( "enableMilestonePlacementEditing", false ); + // `force: true` marks these as server-initiated structural updates + // so the webview applies them even when its tracked position has + // shifted (e.g. after demote moves the cursor to milestone N-1). + // Without the flag the webview's stale guard rejects the message and + // leaves the accordion frozen on the pre-edit structure. safePostMessageToPanel(webviewPanel, { type: "providerSendsInitialContentPaginated", rev, @@ -192,6 +197,7 @@ export async function sendMilestoneRefreshToWebview( validationCountAudio: validationCountAudio, useSubdivisionNumberLabels, enableMilestonePlacementEditing, + force: true, }); safePostMessageToPanel(webviewPanel, { @@ -199,6 +205,7 @@ export async function sendMilestoneRefreshToWebview( rev, milestoneIndex: currentPosition.milestoneIndex, subsectionIndex: currentPosition.subsectionIndex, + force: true, }); debug(`[sendMilestoneRefreshToWebview] Sent updated milestone index and refreshCurrentPage for milestone ${currentPosition.milestoneIndex}, subsection ${currentPosition.subsectionIndex}`); } else { diff --git a/types/index.d.ts b/types/index.d.ts index ae02a4313..7a390b958 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -2072,6 +2072,17 @@ type EditorReceiveMessages = * restructures the document, not just relabels regions. */ enableMilestonePlacementEditing?: boolean; + /** + * When true, this payload is a server-initiated push (e.g. after a + * structural milestone edit on the source or a mirror from another + * webview) and must be applied even when the webview's tracked + * position no longer matches `currentMilestoneIndex` / + * `currentSubsectionIndex`. The webview should also realign its refs + * to the message's position. Without this flag the webview's stale + * guard would reject the message and leave the accordion frozen on + * the pre-edit structure. + */ + force?: boolean; } | { type: "providerSendsCellPage"; @@ -2272,6 +2283,13 @@ type EditorReceiveMessages = /** Optional position from provider; webview uses this when present to avoid reverting during navigation. */ milestoneIndex?: number; subsectionIndex?: number; + /** + * When true, this refresh follows a server-initiated structural + * change that shifted the cursor. The webview must request cells at + * the message's `milestoneIndex` / `subsectionIndex` instead of its + * own (now-stale) refs. + */ + force?: boolean; } | { type: "asrConfig"; content: { endpoint: string; authToken?: string; }; } | { type: "startBatchTranscription"; content: { count: number; }; } diff --git a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx index ce7805253..d4d65c847 100755 --- a/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/CodexCellEditor.tsx @@ -877,14 +877,31 @@ const CodexCellEditor: React.FC = () => { milestoneCellsCacheRef.current.clear(); progressCacheRef.current.clear(); - // Prefer: 1) in-flight navigation (latestRequestRef), 2) refs (webview's current position). - // Always use refs over the provider message so our position wins when the provider sends a stale - // position (e.g. source doc: provider hasn't processed our request yet). Refs are updated when we - // navigate, use cache, or receive handleCellPage/setContentPaginated. - const pending = latestRequestRef.current; + // Position selection cascade: + // 1. `force: true` from server (structural edit shifted the cursor) → use + // the message's position; refs are stale. + // 2. In-flight navigation (`latestRequestRef`) → use that. + // 3. Webview refs (current position). + // Refs win over a non-forced provider message so our position is preserved + // when the provider sends a stale refresh (e.g. user navigated mid-flight). + const forcedPosition = + message.force === true && + typeof message.milestoneIndex === "number" && + typeof message.subsectionIndex === "number" + ? { milestoneIdx: message.milestoneIndex, subsectionIdx: message.subsectionIndex } + : null; + const pending = forcedPosition ?? latestRequestRef.current; const milestoneIdx = pending?.milestoneIdx ?? currentMilestoneIndexRef.current; const subsectionIdx = pending?.subsectionIdx ?? currentSubsectionIndexRef.current; + // Realign refs immediately on a forced refresh so a follow-up + // providerSendsInitialContentPaginated isn't bounced as stale. + if (forcedPosition) { + latestRequestRef.current = null; + currentMilestoneIndexRef.current = forcedPosition.milestoneIdx; + currentSubsectionIndexRef.current = forcedPosition.subsectionIdx; + } + // Request fresh cells for the current page if (requestCellsForMilestoneRef.current) { requestCellsForMilestoneRef.current(milestoneIdx, subsectionIdx); @@ -1610,7 +1627,8 @@ const CodexCellEditor: React.FC = () => { currentMilestoneIdx: number, currentSubsectionIdx: number, isSourceTextValue: boolean, - sourceCellMapValue: { [k: string]: { content: string; versions: string[] } } + sourceCellMapValue: { [k: string]: { content: string; versions: string[] } }, + force?: boolean ) => { // On first load, always accept the initial content regardless of ref values. // The refs start at (0,0) but the provider may send a cached position (e.g. chapter 3 → milestone 2), @@ -1619,9 +1637,12 @@ const CodexCellEditor: React.FC = () => { // Ignore initial content when we're already on a different page (e.g. source: provider sent // providerSendsInitialContentPaginated (0,0) after we navigated to (0,1), which would revert us). - // But never reject the very first content message - that's our initial load. + // But never reject the very first content message - that's our initial load. `force: true` + // marks server-initiated structural updates (e.g. promote/demote shifts the cursor); those + // must apply regardless of refs and realign refs to the message's position. if ( !isFirstContent && + !force && (currentMilestoneIndexRef.current !== currentMilestoneIdx || currentSubsectionIndexRef.current !== currentSubsectionIdx) ) { @@ -1637,6 +1658,12 @@ const CodexCellEditor: React.FC = () => { ); return; } + if (force) { + // A structural edit on the server side just shifted the cursor; + // drop any in-flight navigation request so it doesn't reapply + // the pre-edit position over the new state. + latestRequestRef.current = null; + } // Mark that we've received initial content so subsequent messages go through the stale guard hasReceivedInitialContentRef.current = true; diff --git a/webviews/codex-webviews/src/CodexCellEditor/__tests___/initialContentStaleGuard.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/__tests___/initialContentStaleGuard.test.tsx index dcb8be6c9..9778b7630 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/__tests___/initialContentStaleGuard.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/__tests___/initialContentStaleGuard.test.tsx @@ -65,13 +65,15 @@ function StaleGuardHarness(props: { currentMilestoneIdx: number, currentSubsectionIdx: number, _isSourceTextValue: boolean, - _sourceCellMapValue: { [k: string]: { content: string; versions: string[] } } + _sourceCellMapValue: { [k: string]: { content: string; versions: string[] } }, + force?: boolean ) => { // ---- Exact same guard logic as CodexCellEditor.tsx ---- const isFirstContent = !hasReceivedInitialContentRef.current; if ( !isFirstContent && + !force && (currentMilestoneIndexRef.current !== currentMilestoneIdx || currentSubsectionIndexRef.current !== currentSubsectionIdx) ) { @@ -111,13 +113,15 @@ const dispatchInitialContent = ( cells: QuillCellContent[], currentMilestoneIndex: number, currentSubsectionIndex: number, - rev?: number + rev?: number, + force?: boolean ) => { window.dispatchEvent( new MessageEvent("message", { data: { type: "providerSendsInitialContentPaginated", ...(rev !== undefined ? { rev } : {}), + ...(force !== undefined ? { force } : {}), milestoneIndex, cells, currentMilestoneIndex, @@ -259,4 +263,73 @@ describe("setContentPaginated stale-content guard (initial load)", () => { expect(onAccepted).toHaveBeenCalledTimes(2); expect(onRejected).not.toHaveBeenCalled(); }); + + it("force=true bypasses the stale guard so server-initiated structural updates always apply", () => { + // Scenario: user has scrolled to milestone 2 (refs are (2, 0)). The user + // clicks demote on milestone 2 on the source side; the provider merges + // milestone 2 into milestone 1 and shifts the cached cursor to (1, 0). + // Without `force` the webview would compare refs (2, 0) !== (1, 0) and + // reject the refresh, leaving the accordion frozen on the pre-demote + // structure. With `force` the message is applied unconditionally and + // refs realign to (1, 0). + const onAccepted = vi.fn(); + const onRejected = vi.fn(); + + render(); + + const beforeDemote = mkMilestoneIndex([ + { value: "Mark 1", cellIndex: 0 }, + { value: "Mark 2", cellIndex: 50 }, + { value: "Mark 3", cellIndex: 100 }, + ]); + + act(() => { + dispatchInitialContent( + beforeDemote, + [mkCell("MRK 3:1", "chapter 3")], + 2, + 0, + 1 + ); + }); + expect(onAccepted).toHaveBeenCalledTimes(1); + + // Provider issues a forced refresh after demote: shifted to milestone 1. + const afterDemote = mkMilestoneIndex([ + { value: "Mark 1", cellIndex: 0 }, + { value: "Mark 3", cellIndex: 100 }, + ]); + act(() => { + dispatchInitialContent( + afterDemote, + [mkCell("MRK 2:1", "merged")], + 1, + 0, + 2, + true + ); + }); + + expect(onAccepted).toHaveBeenCalledTimes(2); + expect(onAccepted).toHaveBeenLastCalledWith(1, 0, [ + expect.objectContaining({ cellMarkers: ["MRK 2:1"] }), + ]); + expect(onRejected).not.toHaveBeenCalled(); + + // After the forced refresh, refs are now (1, 0). A subsequent stale + // (non-forced) message for milestone 0 must still be rejected — the + // force flag is per-message, not a permanent override. + act(() => { + dispatchInitialContent( + afterDemote, + [mkCell("MRK 1:1", "ch1")], + 0, + 0, + 3 + ); + }); + expect(onAccepted).toHaveBeenCalledTimes(2); + expect(onRejected).toHaveBeenCalledTimes(1); + expect(onRejected).toHaveBeenCalledWith(0, 0); + }); }); diff --git a/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts b/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts index 08c81e932..3999fac51 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts +++ b/webviews/codex-webviews/src/CodexCellEditor/hooks/useVSCodeMessageHandler.ts @@ -114,7 +114,8 @@ interface UseVSCodeMessageHandlerProps { currentMilestoneIndex: number, currentSubsectionIndex: number, isSourceText: boolean, - sourceCellMap: { [k: string]: { content: string; versions: string[]; }; } + sourceCellMap: { [k: string]: { content: string; versions: string[]; }; }, + force?: boolean ) => void; handleCellPage?: ( milestoneIndex: number, @@ -350,7 +351,8 @@ export const useVSCodeMessageHandler = ({ message.currentMilestoneIndex, message.currentSubsectionIndex, message.isSourceText, - message.sourceCellMap + message.sourceCellMap, + (message as { force?: boolean; }).force === true ); } try { From 1e2e997e870f021b6591a003af4bdd39801634c8 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Fri, 15 May 2026 21:34:37 -0500 Subject: [PATCH 31/34] Capture pre-mutation source roots so the merge mirror reaches the target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commitMergeMilestoneIntoPrevious mutated the source first and then called sourceAndTargetMilestoneRootsMatch on the post-mutation source to gate the structural mirror. After the merge, the source's previous milestone has absorbed the removed milestone's roots, so its root id list never matched the still-unmutated target. rootsMatchPrev always returned false and every remove/demote silently skipped the target — the target stayed on the pre-edit structure even when both sides shared identical milestone ids and roots before the click. Snapshot both the previous and removed milestones' root id lists from the source before any mutation, then compare those snapshots against the target's current roots. The divergence guard still fires when the target genuinely drifted, but a freshly-paired source / target pair now mirrors cleanly through demote and remove. Tests cover the mirrored delete, the mirrored demote (including the boundary placement and subdivisionNamesFromSource carry-over), and the no-op when target milestone ids have diverged. --- .../codexCellEditorMessagehandling.ts | 33 ++- src/test/suite/milestoneSubdivisions.test.ts | 203 ++++++++++++++++++ 2 files changed, 225 insertions(+), 11 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index 3cab15bbf..aec6554c8 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -627,6 +627,14 @@ async function commitMergeMilestoneIntoPrevious({ const removedMilestoneCellId = removedCell.metadata.id; const removedRootIds = document.getRootContentCellIdsForMilestone(milestoneIndex); + // Capture both milestones' root id lists BEFORE we mutate the source so + // we can compare them against the (still pre-mutation) target inside + // the mirror block. The merge expands the previous milestone's root + // set to include the removed one's roots, so reading the source's + // roots POST-mutation would never match the still-unmutated target + // and the structural mirror would silently skip. + const previousRootIdsBefore = + document.getRootContentCellIdsForMilestone(milestoneIndex - 1); const boundaryAnchorCellId = removedRootIds[0]; const removedLabel = (removedCell.value as string | undefined) ?? ""; const removedData = readMilestoneSubdivisionData(removedCell); @@ -695,21 +703,24 @@ async function commitMergeMilestoneIntoPrevious({ // backed by cells with matching IDs. We compare BOTH milestones' // root cell ID lists so a previously-divergent pair (e.g. user // mutated the target file independently) doesn't end up with a - // missing milestone on one side after the merge. + // missing milestone on one side after the merge. We use the + // source's PRE-mutation root ids captured above; reading the + // source's roots NOW would include the merge expansion and + // never match the still-unmutated target. const removedCellId = targetRemoved ? targetDocument.getCellByIndex(targetRemoved.cellIndex)?.metadata?.id : undefined; const removedIdMatches = removedCellId === removedMilestoneCellId; - const rootsMatchPrev = sourceAndTargetMilestoneRootsMatch( - document, - targetDocument, - milestoneIndex - 1 - ); - const rootsMatchRemoved = sourceAndTargetMilestoneRootsMatch( - document, - targetDocument, - milestoneIndex - ); + const targetPreviousRootIds = + targetDocument.getRootContentCellIdsForMilestone(milestoneIndex - 1); + const targetRemovedRootIds = + targetDocument.getRootContentCellIdsForMilestone(milestoneIndex); + const rootsMatchPrev = + previousRootIdsBefore.length === targetPreviousRootIds.length && + previousRootIdsBefore.every((id, i) => id === targetPreviousRootIds[i]); + const rootsMatchRemoved = + removedRootIds.length === targetRemovedRootIds.length && + removedRootIds.every((id, i) => id === targetRemovedRootIds[i]); if (!targetRemoved || !targetPrevious || !removedIdMatches || !rootsMatchPrev || !rootsMatchRemoved) { console.warn(`${logPrefix} Source/target diverge; skipping structural mirror.`, { diff --git a/src/test/suite/milestoneSubdivisions.test.ts b/src/test/suite/milestoneSubdivisions.test.ts index cd97df4e5..765a4c3d4 100644 --- a/src/test/suite/milestoneSubdivisions.test.ts +++ b/src/test/suite/milestoneSubdivisions.test.ts @@ -1724,4 +1724,207 @@ suite("Milestone Subdivisions Test Suite", () => { ); }); }); + + // --------------------------------------------------------------------------- + // Milestone structural mirror: promote / demote / remove must mirror the + // structural change onto the paired target document. Regression target: + // the pre-fix code compared the source's post-mutation root ids against + // the (still unmutated) target inside `commitMergeMilestoneIntoPrevious`, + // so the divergence guard fired on every merge and the target silently + // stayed in its pre-edit state. + // --------------------------------------------------------------------------- + + suite("Milestone structural mirror (source -> target)", () => { + let pairedTempUri: vscode.Uri | undefined; + + teardown(async () => { + if (pairedTempUri) { + await deleteIfExists(pairedTempUri); + pairedTempUri = undefined; + } + }); + + function buildTwoMilestoneCells() { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: "Luke 1", + metadata: { type: CodexCellTypes.MILESTONE, id: "m1" }, + }, + ]; + for (let i = 1; i <= 5; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + cells.push({ + kind: 2, + languageId: "scripture", + value: "Luke 2", + metadata: { type: CodexCellTypes.MILESTONE, id: "m2" }, + }); + for (let i = 6; i <= 10; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + return cells; + } + + async function createPairedTargetDocument(cells: any[]): Promise<{ + uri: vscode.Uri; + document: CodexCellDocument; + }> { + const targetUri = await createTempCodexFile( + `test-target-${Date.now()}-${Math.random().toString(36).slice(2)}.codex`, + { cells, metadata: {} } + ); + pairedTempUri = targetUri; + const targetDocument = await provider.openCustomDocument( + targetUri, + { backupId: undefined }, + new vscode.CancellationTokenSource().token + ); + return { uri: targetUri, document: targetDocument }; + } + + function stampSourceUri(document: CodexCellDocument) { + (document as any).uri = vscode.Uri.parse("file:///test.source"); + } + + function stubProviderForStructuralMirror( + p: CodexCellEditorProvider, + targetDoc: CodexCellDocument, + targetUri: vscode.Uri + ) { + sinon.stub(p, "saveCustomDocument").resolves(); + sinon.stub(p, "refreshWebview").resolves(); + sinon.stub(p, "getWebviewPanelForUri").returns(undefined); + (p as any).currentMilestoneSubsectionMap = new Map(); + sinon.stub(CodexCellDocument.prototype as any, "refreshAuthor").resolves(); + sinon.stub( + CodexCellDocument.prototype as any, + "updateCellMilestoneIndices" + ).resolves(); + sinon.stub(p, "getPairedNotebookUri").returns(targetUri); + sinon.stub(p, "getOrOpenDocumentForUri").resolves(targetDoc); + } + + async function invokeHandler( + handlerName: string, + { document, content }: { document: CodexCellDocument; content: any; } + ): Promise { + const handler = __testOnlyMessageHandlers[handlerName]; + assert.ok(handler, `${handlerName} handler must be registered`); + await handler({ + event: { command: handlerName, content } as any, + document, + webviewPanel: {} as any, + provider, + updateWebview: () => { /* no-op */ }, + }); + } + + test("removeMilestone mirrors the structural delete onto the target", async () => { + const sourceDoc = await createDocumentWithCells(buildTwoMilestoneCells()); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument( + buildTwoMilestoneCells() + ); + stubProviderForStructuralMirror(provider, targetDoc, targetUri); + + const targetBefore = targetDoc.buildMilestoneIndex(50); + assert.strictEqual( + targetBefore.milestones.length, + 2, + "Target should start with two milestones" + ); + + await invokeHandler("removeMilestone", { + document: sourceDoc, + content: { milestoneIndex: 1 }, + }); + + const sourceAfter = sourceDoc.buildMilestoneIndex(50); + assert.strictEqual(sourceAfter.milestones.length, 1, "Source should be merged"); + + // Regression: pre-fix the merge silently skipped the mirror because + // the divergence check compared the source's POST-mutation roots + // (v1..v10) against the target's pre-mutation roots (v1..v5), so + // the rootsMatchPrev check always failed. + const targetAfter = targetDoc.buildMilestoneIndex(50); + assert.strictEqual( + targetAfter.milestones.length, + 1, + "Target should mirror the structural delete and end up with one milestone" + ); + }); + + test("demoteMilestoneToSubdivision mirrors the merge + boundary onto the target", async () => { + const sourceDoc = await createDocumentWithCells(buildTwoMilestoneCells()); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument( + buildTwoMilestoneCells() + ); + stubProviderForStructuralMirror(provider, targetDoc, targetUri); + + await invokeHandler("demoteMilestoneToSubdivision", { + document: sourceDoc, + content: { milestoneIndex: 1 }, + }); + + const targetAfter = targetDoc.buildMilestoneIndex(50); + assert.strictEqual( + targetAfter.milestones.length, + 1, + "Target should mirror the demote merge" + ); + + const survivor = targetDoc.getCellByIndex(targetAfter.milestones[0].cellIndex); + const data = survivor?.metadata?.data as any; + assert.deepStrictEqual( + data?.subdivisions, + [{ startCellId: "v6" }], + "Target's surviving milestone keeps the demoted seam as a subdivision break" + ); + assert.deepStrictEqual( + data?.subdivisionNamesFromSource, + { v6: "Luke 2" }, + "Source's demoted label is mirrored into subdivisionNamesFromSource so the target shows it by default" + ); + }); + + test("mirror is skipped when the target's milestone cell ID has diverged", async () => { + // Pair a source whose milestone IDs are m1/m2 against a target + // whose milestone IDs differ. The mirror must NOT touch the + // target's milestone structure. + const sourceDoc = await createDocumentWithCells(buildTwoMilestoneCells()); + stampSourceUri(sourceDoc); + const targetCells = buildTwoMilestoneCells(); + targetCells[6].metadata.id = "different-id"; // the second milestone cell + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument( + targetCells + ); + stubProviderForStructuralMirror(provider, targetDoc, targetUri); + + await invokeHandler("removeMilestone", { + document: sourceDoc, + content: { milestoneIndex: 1 }, + }); + + const targetAfter = targetDoc.buildMilestoneIndex(50); + assert.strictEqual( + targetAfter.milestones.length, + 2, + "Diverged target should be left intact" + ); + }); + }); }); From 71ce954d82838cef4c6a1a6bdac9331b40f8efb6 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Fri, 15 May 2026 22:57:44 -0500 Subject: [PATCH 32/34] Preserve target subdivision names when mirroring promote/demote Merge demote/remove on the paired file now folds the translator's subdivisionNames from both milestones onto the surviving cell, instead of leaving overrides stranded on the soft-deleted milestone. When promoting a break on the source, a target-only name at the seam wins for the new milestone label so it is not replaced by a source-only placeholder. Tests cover demote (names move to survivor) and promote (translator seam label on the new milestone). --- .../codexCellEditorMessagehandling.ts | 90 ++++++++++++++----- src/test/suite/milestoneSubdivisions.test.ts | 83 +++++++++++++++++ 2 files changed, 149 insertions(+), 24 deletions(-) diff --git a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts index aec6554c8..047a78537 100644 --- a/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts +++ b/src/providers/codexCellEditorProvider/codexCellEditorMessagehandling.ts @@ -523,6 +523,33 @@ function partitionSubdivisionNames( return { kept, moved }; } +/** + * When a milestone is removed (merge / demote), merges the surviving and + * removed milestones' `subdivisionNames` maps onto the survivor the same way + * as the source-side `mergedSourceNames` block: non-`__start__` keys keep + * their cell-id keys; the removed milestone's implicit start re-keys onto the + * seam anchor when `preserveBoundary`, else dropped. + */ +function mergeSubdivisionNameMapsForSurvivorAfterRemovedMilestone( + previousNames: { [k: string]: string }, + removedNames: { [k: string]: string }, + boundaryAnchorCellId: string | undefined, + preserveBoundary: boolean +): { [k: string]: string } { + const merged: { [k: string]: string } = { ...previousNames }; + for (const [key, value] of Object.entries(removedNames)) { + if (typeof value !== "string" || value.length === 0) continue; + if (key === FIRST_SUBDIVISION_KEY) { + if (preserveBoundary && boundaryAnchorCellId) { + merged[boundaryAnchorCellId] = value; + } + continue; + } + merged[key] = value; + } + return merged; +} + /** * Validates that the source and target documents agree on which root content * cells belong to a given milestone index. Used as a pre-flight before any @@ -653,23 +680,12 @@ async function commitMergeMilestoneIntoPrevious({ // travels with the cells). All other named entries keep their cell-ID // keys verbatim — they still resolve to the same root cells, just inside // a wider milestone range now. - const mergedSourceNames: { [k: string]: string; } = { ...previousData.subdivisionNames }; - for (const [key, value] of Object.entries(removedData.subdivisionNames)) { - if (typeof value !== "string" || value.length === 0) continue; - if (key === FIRST_SUBDIVISION_KEY) { - if (preserveBoundary && boundaryAnchorCellId) { - // Demote: the removed milestone's __start__ name becomes the - // boundary placement's name (overriding any inline name we - // already stamped from `removedLabel`, since explicit - // subdivision names are more specific than the milestone - // label). For pure remove, drop it (no boundary placement - // exists to attach the name to). - mergedSourceNames[boundaryAnchorCellId] = value; - } - continue; - } - mergedSourceNames[key] = value; - } + const mergedSourceNames = mergeSubdivisionNameMapsForSurvivorAfterRemovedMilestone( + previousData.subdivisionNames, + removedData.subdivisionNames, + boundaryAnchorCellId, + preserveBoundary + ); const cancellationToken = new vscode.CancellationTokenSource().token; @@ -733,16 +749,28 @@ async function commitMergeMilestoneIntoPrevious({ const targetPreviousCell = targetDocument.getCellByIndex( targetPrevious.cellIndex ); - if (targetPreviousCell?.metadata?.id) { + const targetRemovedCell = targetDocument.getCellByIndex( + targetRemoved.cellIndex + ); + if (targetPreviousCell?.metadata?.id && targetRemovedCell) { + const targetPreviousData = readMilestoneSubdivisionData(targetPreviousCell); + const targetRemovedData = readMilestoneSubdivisionData(targetRemovedCell); + const mergedTargetLocalNames = + mergeSubdivisionNameMapsForSurvivorAfterRemovedMilestone( + targetPreviousData.subdivisionNames, + targetRemovedData.subdivisionNames, + boundaryAnchorCellId, + preserveBoundary + ); await targetDocument.refreshAuthor(); targetDocument.softDeleteCell(removedMilestoneCellId); - // Mirror the source's localized label/name map into the - // target's `subdivisionNamesFromSource` so the translator - // sees the merged section labels by default. The target's - // own `subdivisionNames` is left alone — translators are - // free to keep / override their per-side labels. + // Mirror source defaults into `subdivisionNamesFromSource`; + // fold the translator's own `subdivisionNames` from both + // milestones so names they set on the removed side travel + // onto the survivor (same semantics as the source merge). targetDocument.updateCellData(targetPreviousCell.metadata.id, { subdivisions: merged.placements, + subdivisionNames: mergedTargetLocalNames, subdivisionNamesFromSource: mergedSourceNames, }); mirroredTargetDocument = targetDocument; @@ -1038,6 +1066,20 @@ async function commitSplitMilestoneAtAnchor({ }); } else if (targetOriginalCell?.metadata?.id) { const targetData = readMilestoneSubdivisionData(targetOriginalCell); + // Target-only label at the promoted seam: prefer it for the + // new milestone's cell.value so a translator-named break is + // not replaced by the source-only placeholder (e.g. "New + // milestone") when the source had no name at this anchor. + const rawTargetBoundaryLocal = + targetData.subdivisionNames[boundaryCellId]; + const targetLocalBoundaryLabel = + typeof rawTargetBoundaryLocal === "string" && + rawTargetBoundaryLocal.trim().length > 0 + ? rawTargetBoundaryLocal.trim() + : undefined; + const targetValueOverride = + targetLocalBoundaryLabel ?? valueOverride; + const targetNamePartition = partitionSubdivisionNames( targetData.subdivisionNames, keepKeys, @@ -1072,7 +1114,7 @@ async function commitSplitMilestoneAtAnchor({ targetDocument.insertMilestoneCell({ newCellId: insertedMilestoneCellId, referenceCellId: boundaryCellId, - valueOverride, + valueOverride: targetValueOverride, initialData: targetInitialData, }); // Update the original target milestone with the BEFORE diff --git a/src/test/suite/milestoneSubdivisions.test.ts b/src/test/suite/milestoneSubdivisions.test.ts index 765a4c3d4..68a4ea2c1 100644 --- a/src/test/suite/milestoneSubdivisions.test.ts +++ b/src/test/suite/milestoneSubdivisions.test.ts @@ -1901,6 +1901,89 @@ suite("Milestone Subdivisions Test Suite", () => { ); }); + test("demote folds target translator subdivisionNames from the removed milestone onto the survivor", async () => { + const sourceCells = buildTwoMilestoneCells(); + const targetCells = buildTwoMilestoneCells(); + targetCells[6].metadata.data = { + ...(targetCells[6].metadata.data ?? {}), + subdivisionNames: { v8: "Tail EN" }, + }; + + const sourceDoc = await createDocumentWithCells(sourceCells); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument(targetCells); + stubProviderForStructuralMirror(provider, targetDoc, targetUri); + + await invokeHandler("demoteMilestoneToSubdivision", { + document: sourceDoc, + content: { milestoneIndex: 1 }, + }); + + const targetAfter = targetDoc.buildMilestoneIndex(50); + const survivor = targetDoc.getCellByIndex(targetAfter.milestones[0].cellIndex); + const data = survivor?.metadata?.data as any; + assert.strictEqual( + data?.subdivisionNames?.v8, + "Tail EN", + "Target local names on the removed milestone must move onto the surviving milestone" + ); + }); + + test("promote keeps the target milestone label when the translator named the seam locally", async () => { + function buildOneMilestoneWithBreakAtV6() { + const cells: any[] = [ + { + kind: 2, + languageId: "scripture", + value: "Luke 1", + metadata: { + type: CodexCellTypes.MILESTONE, + id: "m1", + data: { + subdivisions: [{ startCellId: "v6" }], + }, + }, + }, + ]; + for (let i = 1; i <= 10; i++) { + cells.push({ + kind: 2, + languageId: "scripture", + value: `verse ${i}`, + metadata: { type: CodexCellTypes.TEXT, id: `v${i}` }, + }); + } + return cells; + } + + const sourceCells = buildOneMilestoneWithBreakAtV6(); + const targetCells = buildOneMilestoneWithBreakAtV6(); + targetCells[0].metadata.data = { + ...targetCells[0].metadata.data, + subdivisionNames: { v6: "Translator section" }, + }; + + const sourceDoc = await createDocumentWithCells(sourceCells); + stampSourceUri(sourceDoc); + const { uri: targetUri, document: targetDoc } = await createPairedTargetDocument(targetCells); + stubProviderForStructuralMirror(provider, targetDoc, targetUri); + + await invokeHandler("promoteSubdivisionToMilestone", { + document: sourceDoc, + content: { milestoneIndex: 0, subdivisionKey: "v6" }, + }); + + const targetAfter = targetDoc.buildMilestoneIndex(50); + assert.strictEqual(targetAfter.milestones.length, 2, "Target should have split mirroring source"); + + const newMs = targetDoc.getCellByIndex(targetAfter.milestones[1].cellIndex); + assert.strictEqual( + newMs?.value, + "Translator section", + "Target-only name at the promoted seam should win for the new milestone label" + ); + }); + test("mirror is skipped when the target's milestone cell ID has diverged", async () => { // Pair a source whose milestone IDs are m1/m2 against a target // whose milestone IDs differ. The mirror must NOT touch the From 7a7e09bcd8f3fe840b99c3a5baabc8e7cb818f0d Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Fri, 15 May 2026 23:42:23 -0500 Subject: [PATCH 33/34] test(milestone): align demote mirror expectations with subdivisions[].name Target mirror now preserves the seam label on the subdivision row rather than only subdivisionNamesFromSource; update assertions accordingly. --- src/test/suite/milestoneSubdivisions.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/suite/milestoneSubdivisions.test.ts b/src/test/suite/milestoneSubdivisions.test.ts index 68a4ea2c1..b8aef9dca 100644 --- a/src/test/suite/milestoneSubdivisions.test.ts +++ b/src/test/suite/milestoneSubdivisions.test.ts @@ -1891,13 +1891,13 @@ suite("Milestone Subdivisions Test Suite", () => { const data = survivor?.metadata?.data as any; assert.deepStrictEqual( data?.subdivisions, - [{ startCellId: "v6" }], - "Target's surviving milestone keeps the demoted seam as a subdivision break" + [{ startCellId: "v6", name: "Luke 2" }], + "Target's surviving milestone keeps the demoted seam as a subdivision break with the mirrored label" ); assert.deepStrictEqual( - data?.subdivisionNamesFromSource, - { v6: "Luke 2" }, - "Source's demoted label is mirrored into subdivisionNamesFromSource so the target shows it by default" + data?.subdivisionNamesFromSource ?? {}, + {}, + "Preserved demoted label is stored on subdivisions[].name, not duplicated in subdivisionNamesFromSource" ); }); From 6609de2f45b77f7cff7f7b6740ec4466163e7892 Mon Sep 17 00:00:00 2001 From: Luke-Bilhorn Date: Sat, 16 May 2026 01:10:32 -0500 Subject: [PATCH 34/34] Fix subdivision rename input focus loss on parent re-renders The accordion's focus-trap useEffect bundled `accordionRef.current.focus()` with the ESC + click-outside listener registration, and depended on the parent-supplied `onClose` callback. ChapterNavigationHeader passed `onClose` as an inline arrow, so every parent re-render produced a fresh reference, churning the deps and re-stealing focus from any open inline rename input. Subdivision renames (input lives in a plain div inside AccordionContent) were the visible casualty; milestone renames survived because their input lives inside an AccordionTrigger button that has its own focus management. Split the effect into two: - one that auto-focuses the accordion exactly once per open transition, - one that re-attaches the ESC / click-outside listeners when `onClose` flips (cheap, no focus side effect). Also stabilize `onClose` in ChapterNavigationHeader with `useCallback` so the listener-attach effect doesn't churn unnecessarily. Adds a regression test that re-renders the accordion with a new `onClose` identity while the subdivision rename input is focused and asserts focus stays on the input. --- .../ChapterNavigationHeader.tsx | 8 ++- .../components/MilestoneAccordion.test.tsx | 64 ++++++++++++++++++ .../components/MilestoneAccordion.tsx | 66 +++++++++++-------- 3 files changed, 109 insertions(+), 29 deletions(-) diff --git a/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx b/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx index 1c36c8557..b779808e1 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/ChapterNavigationHeader.tsx @@ -168,6 +168,12 @@ ChapterNavigationHeaderProps) { const [isMetadataModalOpen, setIsMetadataModalOpen] = useState(false); const [autoDownloadAudioOnOpen, setAutoDownloadAudioOnOpenState] = useState(false); const [showMilestoneAccordion, setShowMilestoneAccordion] = useState(false); + // Stable callback so MilestoneAccordion's effect deps don't churn on every + // render of this header — keeps inline-rename focus from being stolen when + // we re-attach ESC / click-outside listeners. + const closeMilestoneAccordion = useCallback(() => { + setShowMilestoneAccordion(false); + }, []); const [isDropdownOpen, setIsDropdownOpen] = useState(false); const chapterTitleRef = useRef(null); const headerContainerRef = useRef(null); @@ -1078,7 +1084,7 @@ ChapterNavigationHeaderProps) { setShowMilestoneAccordion(false)} + onClose={closeMilestoneAccordion} milestoneIndex={milestoneIndex} currentMilestoneIndex={currentMilestoneIndex} currentSubsectionIndex={currentSubsectionIndex} diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx index 919f43206..474302a3e 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.test.tsx @@ -947,6 +947,70 @@ describe("MilestoneAccordion - Milestone Editing", () => { expect(renameCalls).toHaveLength(0); }); + // Regression: the accordion used to call `accordionRef.current.focus()` + // inside the same effect that registered ESC + click-outside listeners, + // with the parent-supplied `onClose` in its deps. Every parent re-render + // produced a new inline `onClose` arrow, which churned the deps and + // re-stole focus from any open inline rename input — making it impossible + // to keep typing in the subdivision rename textbox. This test guards + // against that by changing `onClose` between renders and asserting the + // subdivision rename input retains focus. + it("retains subdivision-rename input focus across parent re-renders that change onClose", async () => { + mockGetSubsectionsForMilestone = vi.fn((milestoneIdx: number) => [ + createSubsectionWithKey(`s-${milestoneIdx}-0`, "1-5", "v1", "Opening"), + ]); + const milestoneIndex = createMockMilestoneIndex([{ value: "Luke 1", index: 0 }]); + const renderArgs = { + milestoneIndex, + getSubsectionsForMilestone: mockGetSubsectionsForMilestone, + } as Partial>; + // Use a stable wrapper so we can rerender with a new `onClose` + // identity (mirroring the inline-arrow pattern parents originally + // used) without unmounting the component under test. + const { rerender } = renderMilestoneAccordion({ + ...renderArgs, + onClose: vi.fn(), + }); + + const renameBtn = await screen.findByLabelText("Rename Milestone Subdivision"); + await act(async () => { + fireEvent.click(renameBtn); + }); + + const input = screen.getByDisplayValue("Opening") as HTMLInputElement; + // Simulate a user click into the input (jsdom auto-focuses on .focus()). + await act(async () => { + input.focus(); + }); + expect(document.activeElement).toBe(input); + + // Force a re-render with a fresh `onClose` reference, mimicking a + // parent re-render that passes a new inline arrow. The focus + // useEffect must NOT yank focus back to the accordion wrapper. + await act(async () => { + rerender( + + ); + }); + + expect(document.activeElement).toBe(input); + }); + it("cancel button leaves the existing name untouched", async () => { mockGetSubsectionsForMilestone = vi.fn((milestoneIdx: number) => [ createSubsectionWithKey(`s-${milestoneIdx}-0`, "1-5", "v1", "Opening"), diff --git a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx index 2e6fa5c3f..5c317fc05 100644 --- a/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx +++ b/webviews/codex-webviews/src/CodexCellEditor/components/MilestoneAccordion.tsx @@ -570,40 +570,50 @@ export function MilestoneAccordion({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); - // Focus trap and ESC key handling + // Auto-focus the accordion wrapper ONCE on each open transition. Splitting + // this out from the ESC / click-outside listeners is critical: when those + // listeners' deps (notably `onClose`, often an unstable inline arrow from + // the parent) churn, the combined effect re-fires and steals focus from + // any in-progress inline rename input (subdivision pencil edits especially + // — the milestone rename input lives inside an AccordionTrigger that has + // its own focus management so it's less affected). + const wasOpenRef = useRef(false); useEffect(() => { - if (isOpen && accordionRef.current) { - // Auto-focus the accordion when opened + if (isOpen && !wasOpenRef.current && accordionRef.current) { accordionRef.current.focus(); + } + wasOpenRef.current = isOpen; + }, [isOpen]); - // Handle ESC key press - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") { - onClose(); - } - }; + // ESC + click-outside listeners. These re-attach when `onClose` changes + // reference (cheap), but never touch focus, so inline renames stay sticky. + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; - document.addEventListener("keydown", handleKeyDown); - - // Close when clicking outside - const handleClickOutside = (e: MouseEvent) => { - if ( - accordionRef.current && - !accordionRef.current.contains(e.target as Node) && - anchorRef.current && - !anchorRef.current.contains(e.target as Node) - ) { - onClose(); - } - }; + document.addEventListener("keydown", handleKeyDown); - document.addEventListener("mousedown", handleClickOutside); + const handleClickOutside = (e: MouseEvent) => { + if ( + accordionRef.current && + !accordionRef.current.contains(e.target as Node) && + anchorRef.current && + !anchorRef.current.contains(e.target as Node) + ) { + onClose(); + } + }; - return () => { - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("mousedown", handleClickOutside); - }; - } + document.addEventListener("mousedown", handleClickOutside); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("mousedown", handleClickOutside); + }; }, [isOpen, onClose, anchorRef]); // Sync expanded milestone state when accordion opens