Skip to content

Saving after a load permanently destroys any command that loaded as a placeholder, and each save/load cycle adds another "[Placeholder] " prefix #111

Description

@matt-edmondson

What's wrong

JsonUndoRedoSerializer falls back to a PlaceholderCommand whenever it can't rebuild a saved command. That happens when Data is null, or when Type.GetType can't resolve the type, for example because a plugin assembly isn't loaded yet. PlaceholderCommand (JsonUndoRedoSerializer.cs ~line 262) keeps only the description, navigation context and metadata. It throws away the original type string and data.

On the next SaveStateAsync, ConvertToSerializableCommand (~line 123) serialises the placeholder like any other command:

  • Type is written as ktsu.UndoRedo.Core.Services.PlaceholderCommand, ...
  • Data is written as null, because a placeholder isn't an ISerializableCommand
  • Description is written as the already-prefixed "[Placeholder] …"

This has two consequences:

  1. Data loss. Suppose a file is loaded while a plugin is missing and then saved, for instance by an autosave. The commands that could have been rebuilt once the plugin was back are gone for good.
  2. Growing prefix. Every save/load cycle adds another "[Placeholder] ".

Repro

var svc = new UndoRedoService(new StackManager(), new SaveBoundaryManager(), new CommandMerger());
svc.SetSerializer(new JsonUndoRedoSerializer());

// Prefix growth
svc.Execute(new DelegateCommand("Type hello", () => { }, () => { }));
for (int i = 0; i < 3; i++)
{
    await svc.LoadStateAsync(await svc.SaveStateAsync());
    Console.WriteLine(svc.Commands[0].Description);
}
// [Placeholder] Type hello
// [Placeholder] [Placeholder] Type hello
// [Placeholder] [Placeholder] [Placeholder] Type hello

// Data loss
string json = """{"commands":[{"type":"My.Plugin.SetTextCommand","description":"Set text","data":"hello","metadata":{"changeType":"Modify","affectedItems":[],"timestamp":"2026-01-01T00:00:00+00:00","size":1}}],"currentPosition":0,"saveBoundaries":[],"formatVersion":"json-v1.0","timestamp":"2026-01-01T00:00:00Z"}""";
await svc.LoadStateAsync(Encoding.UTF8.GetBytes(json));      // true; Commands[0] is a PlaceholderCommand
Console.WriteLine(Encoding.UTF8.GetString(await svc.SaveStateAsync()));
// "type":"ktsu.UndoRedo.Core.Services.PlaceholderCommand, …","description":"[Placeholder] Set text" and no "data":
// the original type "My.Plugin.SetTextCommand" and data "hello" are gone

Expected: a placeholder round-trips the original type, description and data unchanged. It should also keep a single [Placeholder] prefix on the description it displays.

Suggested fix

  • Give PlaceholderCommand OriginalType, OriginalDescription and OriginalData. Set them from the SerializableCommand in both placeholder branches of ConvertFromSerializableCommand, the null-data branch and the unresolvable-type branch.
  • In ConvertToSerializableCommand, when command is PlaceholderCommand p, write Type = p.OriginalType, Description = p.OriginalDescription and Data = p.OriginalData.
  • Apply the "[Placeholder] " prefix only to the displayed Description.

Related gap in the same file (a smaller variant, can be fixed alongside)

ValidateShape (~line 92) rejects metadata that is null but not a missing affectedItems. A saved file whose command metadata leaves out affectedItems loads successfully, and LoadStateAsync returns true, but Metadata.AffectedItems is null even though the type is non-nullable. Any later .Count read throws NullReferenceException, and so does new CompositeCommand("c", [loadedCmd]) via SelectMany(c => c.Metadata.AffectedItems). To stay consistent with the #81 contract, reject it in ValidateShape, or normalise it to [].

Acceptance criteria

  • A save → load → save → load cycle keeps the description stable.
  • Loading a command with an unresolvable type and saving again reproduces the original type and data byte-for-byte.
  • Metadata with no affectedItems makes LoadStateAsync return false, or loads with an empty list.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingreadyFully specified; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions