Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,11 @@
"default": false,
"description": "Internal flag indicating the documentContext hoist migration has completed."
},
"codex-project-manager.editHistoryIdsMigrationCompleted": {
"type": "boolean",
"default": false,
"description": "Internal flag indicating the edit history IDs migration has completed."
},
"codex-project-manager.projectName": {
"type": "string",
"default": "",
Expand Down
15 changes: 10 additions & 5 deletions sharedUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,16 @@ export const getCellValueData = (cell: QuillCellContent) => {
// Ensure editHistory exists and is an array
const editHistory = cell.editHistory || [];

// Find the latest edit that matches the current cell content
const latestEditThatMatchesCellValue = editHistory
.slice()
.reverse()
.find((edit) => EditMapUtils.isValue(edit.editMap) && edit.value === cell.cellContent);
// Find the edit matching the current cell content: prefer activeEditId, fall back to value scan
let latestEditThatMatchesCellValue = cell.activeEditId
? editHistory.find((edit) => edit.id === cell.activeEditId)
: undefined;
if (!latestEditThatMatchesCellValue) {
latestEditThatMatchesCellValue = editHistory
.slice()
.reverse()
.find((edit) => EditMapUtils.isValue(edit.editMap) && edit.value === cell.cellContent);
}

// Get audio validation from attachments instead of edits
let audioValidatedBy: ValidationEntry[] = [];
Expand Down
2 changes: 2 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
migration_addGlobalReferences,
migration_cellIdsToUuid,
migration_recoverTempFilesAndMergeDuplicates,
migration_addEditHistoryIds,
} from "./projectManager/utils/migrationUtils";
import { createIndexWithContext } from "./activationHelpers/contextAware/contentIndexes/indexes";
import { StatusBarItem } from "vscode";
Expand Down Expand Up @@ -612,6 +613,7 @@ export async function activate(context: vscode.ExtensionContext) {
await migration_addGlobalReferences(context);
await migration_cellIdsToUuid(context);
await migration_recoverTempFilesAndMergeDuplicates(context);
await migration_addEditHistoryIds(context);

// After migrations complete, trigger sync directly
// (All migrations have finished executing since they're awaited sequentially)
Expand Down
32 changes: 31 additions & 1 deletion src/projectManager/utils/merge/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { CodexCell } from "@/utils/codexNotebookUtils";
import { CodexCellTypes, EditType } from "../../../../types/enums";
import { EditHistory, ValidationEntry, FileEditHistory, ProjectEditHistory } from "../../../../types/index.d";
import { EditMapUtils, deduplicateFileMetadataEdits } from "../../../utils/editMapUtils";
import { generateEditId } from "../../../utils/editHistoryId";
import { normalizeAttachmentUrl } from "@/utils/pathUtils";
import { formatJsonForNotebookFile } from "../../../utils/notebookFileFormattingUtils";
import {
Expand Down Expand Up @@ -759,6 +760,18 @@ function migrateEditHistoryInContent(content: string): string {
}
}

/**
* Ensures all edits in an array have IDs. Adds deterministic IDs to any edits missing them
* (handles pre-migration data during merges).
*/
function ensureEditHistoryIds(edits: any[]): void {
for (const edit of edits) {
if (!edit.id && edit.editMap && edit.timestamp != null && edit.author) {
edit.id = generateEditId(edit.value, edit.timestamp, edit.author);
}
}
}

function mergeTwoCellsUsingResolverLogic(
ourCell: CustomNotebookCellData,
theirCell: CustomNotebookCellData
Expand All @@ -774,6 +787,9 @@ function mergeTwoCellsUsingResolverLogic(
...(theirCell.metadata?.edits || [])
].sort((a, b) => a.timestamp - b.timestamp);

// Ensure all edits have IDs before dedup (handles pre-migration data)
ensureEditHistoryIds(allEdits);

// Remove duplicates based on timestamp, editMap and value, while merging validatedBy entries
const editMap = new Map<string, any>();
allEdits.forEach((edit) => {
Expand All @@ -800,6 +816,15 @@ function mergeTwoCellsUsingResolverLogic(
}
mergedCell.metadata.edits = uniqueEdits;

// Set activeEditId on merged cell: find the latest value edit matching the winning cell value
const latestValueEdit = uniqueEdits
.slice()
.reverse()
.find((e: any) => EditMapUtils.isValue(e.editMap) && e.value === mergedCell.value);
if (latestValueEdit?.id) {
mergedCell.metadata.activeEditId = latestValueEdit.id;
}

// Merge attachments intelligently
const mergedAttachments = mergeAttachments(
ourCell.metadata?.attachments,
Expand Down Expand Up @@ -921,12 +946,14 @@ export async function resolveCodexCustomMerge(
const mergedMetadata = resolveMetadataConflictsUsingEditHistoryForFile(ourMetadata, theirMetadata);

// Combine all metadata edits from both branches and deduplicate
// Similar to cell-level edits deduplication, remove duplicates based on timestamp, editMap and value
const allMetadataEdits = [
...(ourMetadata.edits || []),
...(theirMetadata.edits || [])
];

// Ensure IDs exist on all edits before dedup (handles pre-migration data)
ensureEditHistoryIds(allMetadataEdits);

// Deduplicate edits using the same logic as cell-level edits
mergedMetadata.edits = deduplicateFileMetadataEdits(allMetadataEdits);

Expand Down Expand Up @@ -1675,6 +1702,9 @@ async function resolveMetadataJsonConflict(conflict: ConflictFile): Promise<stri
debugLog(`Applied most recent edit for ${pathKey}: ${JSON.stringify(mostRecentEdit.value)}`);
}

// Ensure IDs exist on all edits before dedup
ensureEditHistoryIds(allEdits);

// Combine edits arrays and deduplicate
resolvedMetadata.edits = deduplicateFileMetadataEdits(allEdits);
} else {
Expand Down
1 change: 1 addition & 0 deletions src/projectManager/utils/migrationCompletionUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const CODEX_PROJECT_MIGRATION_FLAG_KEYS = [
"globalReferencesMigrationCompleted",
"cellIdsToUuidMigrationCompleted",
"tempFilesRecoveryAndDuplicateMergeCompleted",
"editHistoryIdsMigrationCompleted",
] as const;

export type CodexProjectMigrationFlagKey = (typeof CODEX_PROJECT_MIGRATION_FLAG_KEYS)[number];
Expand Down
168 changes: 168 additions & 0 deletions src/projectManager/utils/migrationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import fs from "fs";
import { CodexContentSerializer } from "@/serializer";
import { vrefData } from "@/utils/verseRefUtils/verseData";
import { EditMapUtils } from "@/utils/editMapUtils";
import { generateEditId } from "@/utils/editHistoryId";
import { EditType, CodexCellTypes } from "../../../types/enums";
import type { ValidationEntry } from "../../../types";
import { getAuthApi } from "../../extension";
Expand Down Expand Up @@ -3653,3 +3654,170 @@ export const migration_recoverTempFilesAndMergeDuplicates = async (context?: vsc
console.error("Error running temp files recovery and duplicate merge migration:", error);
}
};

/**
* Migration: Add deterministic IDs to all edit history entries and set activeEditId on cells.
*
* For each .codex and .source file:
* - Adds `id` (via generateEditId) to every edit missing one
* - Sets `activeEditId` on cell metadata to the latest value edit matching cell.value
* - Also handles file-level metadata edits
*
* Fully idempotent: generateEditId is deterministic, and already-populated fields are skipped.
*/
export const migration_addEditHistoryIds = async (context?: vscode.ExtensionContext) => {
try {
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders || workspaceFolders.length === 0) {
return;
}

const migrationKey = "editHistoryIdsMigrationCompleted";
const config = vscode.workspace.getConfiguration("codex-project-manager");
let hasMigrationRun = false;

try {
hasMigrationRun = config.get(migrationKey, false);
} catch (e) {
hasMigrationRun = !!context?.workspaceState.get<boolean>(migrationKey);
}

if (hasMigrationRun) {
debug("Edit history IDs migration already completed, skipping");
return;
}

debug("Running edit history IDs migration...");

const workspaceFolder = workspaceFolders[0];

const codexFiles = await vscode.workspace.findFiles(
new vscode.RelativePattern(workspaceFolder, "**/*.codex")
);
const sourceFiles = await vscode.workspace.findFiles(
new vscode.RelativePattern(workspaceFolder, "**/*.source")
);

const allFiles = [...codexFiles, ...sourceFiles];

if (allFiles.length === 0) {
debug("No codex or source files found, skipping edit history IDs migration");
try {
await config.update(migrationKey, true, vscode.ConfigurationTarget.Workspace);
} catch (e) {
await context?.workspaceState.update(migrationKey, true);
}
return;
}

let processedFiles = 0;
let migratedFiles = 0;

await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Notification,
title: "Adding edit history IDs",
cancellable: false
},
async (progress) => {
for (let i = 0; i < allFiles.length; i++) {
const file = allFiles[i];
progress.report({
message: `Processing ${path.basename(file.fsPath)}`,
increment: (100 / allFiles.length)
});

try {
const wasMigrated = await addEditHistoryIdsToFile(file);
processedFiles++;
if (wasMigrated) {
migratedFiles++;
}
} catch (error) {
console.error(`Error adding edit history IDs to ${file.fsPath}:`, error);
}
}
}
);

try {
await config.update(migrationKey, true, vscode.ConfigurationTarget.Workspace);
} catch (e) {
await context?.workspaceState.update(migrationKey, true);
}

debug(`Edit history IDs migration completed: ${processedFiles} files processed, ${migratedFiles} files migrated`);
if (migratedFiles > 0) {
vscode.window.showInformationMessage(
`Edit history IDs migration complete: ${migratedFiles} files updated`
);
}

} catch (error) {
console.error("Error running edit history IDs migration:", error);
}
};

async function addEditHistoryIdsToFile(fileUri: vscode.Uri): Promise<boolean> {
const fileContent = await vscode.workspace.fs.readFile(fileUri);
const text = new TextDecoder().decode(fileContent);

let notebook: any;
try {
notebook = JSON.parse(text);
} catch {
return false;
}

if (!notebook.cells || !Array.isArray(notebook.cells)) {
return false;
}

let changed = false;

// Process cell-level edits
for (const cell of notebook.cells) {
if (!cell.metadata?.edits || !Array.isArray(cell.metadata.edits)) {
continue;
}

for (const edit of cell.metadata.edits) {
if (!edit.id && edit.editMap && edit.timestamp != null && edit.author) {
edit.id = generateEditId(edit.value, edit.timestamp, edit.author);
changed = true;
}
}

// Set activeEditId if not already set: find latest value edit matching cell.value
if (!cell.metadata.activeEditId && cell.value != null) {
const valueEdits = cell.metadata.edits.filter(
(e: any) => Array.isArray(e.editMap) && e.editMap.length === 1 && e.editMap[0] === "value"
);
for (let i = valueEdits.length - 1; i >= 0; i--) {
if (valueEdits[i].value === cell.value && valueEdits[i].id) {
cell.metadata.activeEditId = valueEdits[i].id;
changed = true;
break;
}
}
}
}

// Process file-level metadata edits
if (notebook.metadata?.edits && Array.isArray(notebook.metadata.edits)) {
for (const edit of notebook.metadata.edits) {
if (!edit.id && edit.editMap && edit.timestamp != null && edit.author) {
edit.id = generateEditId(edit.value, edit.timestamp, edit.author);
changed = true;
}
}
}

if (!changed) {
return false;
}

const updatedText = formatJsonForNotebookFile(notebook);
await atomicWriteUriText(fileUri, updatedText);
return true;
}
Loading