Skip to content

@pierre/diffs v1.4.0

Latest

Choose a tag to compare

@amadeus amadeus released this 04 Sep 03:53

Migrating Editing to Pierre Diffs 1.4

Pierre Diffs 1.4 makes editable components responsible for their active edit session. Applications no longer need to mirror every changed file, diff, or annotation back into the component while the user is typing.

Use component onEditChange to observe or autosave a draft and component onEditComplete as the commit boundary. Return 'accept' to install the completed file or diff and its final annotations, or 'reject' to restore the latest external value and annotations.

API changes

Before 1.4
new Editor(options?) new Editor('file' | 'file-diff', options?, editStateKey?)
onChange(file, annotations, event) onChange(event)
EditorChangeEvent<LAnnotation> EditorChangeEvent<'file' | 'file-diff', LAnnotation, Caret>
editor.getState() / setState() editor.getViewState() / setViewState()
EditorState EditorViewState
Old scroll-only EditorViewState EditorViewportState
persistState / persistStateStorage editStateKey or application-owned storage
createEditor(options) createEditor(editorType, options, editStateKey)
No editor completion observer EditorOptions.onComplete(event)
cleanUp(recycle?: boolean) cleanUp('discard' | 'recycle' | 'complete')

The new EditorChangeEvent contains changes, file, lineAnnotations, and the attached editor. For a diff, event.file is the editable new file, not a FileDiffMetadata object.

Prefer component-level onEditChange and onEditComplete callbacks for normal integrations. Their completion return value decides whether to accept or reject. The lower-level EditorOptions.onChange and EditorOptions.onComplete callbacks are for editor-wide observation. You cannot accept or reject from EditorOptions.onComplete. The editor completion observer receives the exact same frozen event before the component callback and still fires when no component completion handler exists. Its argument is EditorEditCompleteEvent<'file' | 'file-diff', LAnnotation, Caret>, the union of FileEditCompleteEvent and FileDiffEditCompleteEvent exported from @pierre/diffs/edit.

Add the EditorType to TypeScript types

Editors are component-specific in 1.4. Use 'file' for File and 'file-diff' for FileDiff. The constructor infers the type when you do not provide generic arguments:

const editor = new Editor('file');

If you previously specified annotation metadata, add the editor type before it:

type ThreadMetadata = { threadId: string };

const editor = new Editor<'file-diff', ThreadMetadata>('file-diff');
const options: EditorOptions<'file-diff', ThreadMetadata, undefined> = {};

The same editor-type-first order applies to TextDocument, EditState, and EditorInitialState.

Supply metadata arguments to type-only APIs

Type-only APIs such as EditorOptions, EditorFactory, FileOptions, FileDiffOptions, CodeViewOptions, and edit event types require annotation and caret metadata arguments. Use undefined for metadata you do not provide:

type Options = FileDiffOptions<ThreadMetadata, undefined>;
type Change = EditorChangeEvent<'file-diff', ThreadMetadata, undefined>;

Concrete classes, React components, and preload functions retain metadata defaults, so they do not require explicit generic arguments.

Use the 'file' | 'file-diff' union only for code that handles both types.

Move editor type imports

Import editor events, state, selections, document positions, markers, focus options, and factory types from @pierre/diffs/edit.

// Before
import type {
  EditorChangeEvent,
  EditorViewState,
  MarkerSeverity,
} from '@pierre/diffs';

// 1.4
import type {
  EditorChangeEvent,
  EditorViewState,
  MarkerSeverity,
} from '@pierre/diffs/edit';

Replace structural edit types

Pierre only supports its concrete editor and component classes. The structural types that allowed plain objects to look like supported implementations have been removed:

  • Replace DiffsEditor with Editor<'file', LAnnotation> or Editor<'file-diff', LAnnotation>.
  • Replace DiffsTextDocument with the matching TextDocument type.
  • Replace DiffsEditableComponent, EditableInstance, and DiffsBaseComponent with the concrete File or FileDiff class being used.
  • Replace DiffsComponentOptions with the options type for the concrete component, such as FileOptions, FileDiffOptions, or CodeViewOptions.

Use a union only when application code genuinely handles multiple component types. Editor.edit() and EditorOptions.onAttach now expose Pierre's matching component instance rather than a separately maintained interface.

Let components own active edits

Remove handlers that immediately feed changed contents or annotations back through render(), React state, or CodeView item updates. The component now owns the draft and remaps annotation positions during the session. Feeding those values back can create loops or overwrite session-owned annotation positions with stale external positions.

Application state remains the external source of truth. Update the file, diff, and annotations once when the edit is accepted:

  • onEditChange(event) observes the current draft. Use it for autosave, validation, or side effects without updating the active component.
  • File.onEditComplete(event) completes with event.file and final event.lineAnnotations.
  • FileDiff.onEditComplete(event) completes with event.fileDiff, final annotations, and oldFile/newFile for file-pair or patch owners.
  • Component onEditComplete always fires once when the session ends, including unchanged, selection-only, scroll-only, annotation-only, and fully undone sessions. A missing component completion handler rejects.

Vanilla JavaScript

Put lifecycle callbacks on File or FileDiff, then end the session with the disposer returned by editor.edit():

let file = initialFile;
let annotations = initialAnnotations;
let shouldAccept = true;

const view = new File({
  onEditChange(event) {
    autosaveDraft(event.file);
  },
  onEditComplete(event) {
    if (!shouldAccept) return 'reject';

    file = event.file;
    annotations = event.lineAnnotations ?? [];
    return 'accept';
  },
});

view.render({ fileContainer, file, lineAnnotations: annotations });

const editor = new Editor('file', {}, `file:${fileId}`);
const finishEditing = editor.edit(view);

// Save or Cancel sets shouldAccept, then ends the session.
finishEditing();

Completed values have no cacheKey. If the surface uses keyed render caching, assign a fresh key before accepting. Do not reuse the replaced value's key and do not clone the file or diff, simply mutate it for the cacheKey.

event.file.cacheKey = `file:${fileId}:${revision}`;
return 'accept';

React

File, FileDiff, MultiFileDiff, and PatchDiff expose the same callbacks as top-level props. Move onEditChange and onEditComplete out of the React component's options object. Do not call setFile, setDiff, or setAnnotations from onEditChange; update them at completion:

const cancelRequested = useRef(false);

// Reset to false before starting or saving. Set to true before ending via Cancel.
<File
  file={file}
  lineAnnotations={annotations}
  edit={editing}
  editStateKey={`file:${fileId}`}
  onEditChange={(event) => autosaveDraft(event.file)}
  onEditComplete={(event) => {
    if (cancelRequested.current) {
      cancelRequested.current = false;
      return 'reject';
    }

    event.file.cacheKey = `file:${fileId}:${nextRevision()}`;
    setFile(event.file);
    setAnnotations(event.lineAnnotations ?? []);
    return 'accept';
  }}
/>;

Update EditProvider factories to forward all three arguments. The provider no longer reuses editors based on editorOptions object identity.

<EditProvider
  createEditor={(editorType, options, editStateKey) =>
    new Editor(editorType, options, editStateKey)
  }
>
  {children}
</EditProvider>

When the factory is declared outside JSX, use the exported EditorFactory type so its options and returned editor stay matched to the requested editor type:

import type { EditorFactory } from '@pierre/diffs/edit';

const createEditor: EditorFactory<ThreadMetadata, undefined> = (
  editorType,
  options,
  editStateKey
) => new Editor(editorType, options, editStateKey);

CodeView

CodeView callbacks are now event-first. Completion also receives nextItem, which contains the completed value and annotations, edit: false, and an incremented version. onItemEditComplete runs whenever the item session ends, including unchanged, annotation-only, and fully undone sessions. Collapse and virtualization only suspend the session.

const codeView = new CodeView({
  getEditStateKey(item) {
    return `edit-state:${item.id}`;
  },
  createEditor(editorType, options, editStateKey) {
    return new Editor(editorType, options, editStateKey);
  },
  onItemEditComplete(event, item, nextItem) {
    return 'accept';
  },
});

The createEditor option remains optional, but a provided factory must always return the editor requested by editorType.

Vanilla CodeView installs nextItem after acceptance. Controlled React CodeView users must also put nextItem into their items state. Rejecting keeps the latest external item. Completion caused by removal or unmount reports the result but cannot reinstall the removed item.

Replace persistState

persistState, persistStateStorage, IStateStorage, and built-in IndexedDB storage have been removed. FileContents.cacheKey now identifies only render/highlighting cache/WorkerPool entries, not edit state.

Use a stable editStateKey to retain the draft, undo/redo history, selections, and eligible scroll state between editor instances in the same session:

<File edit file={file} editStateKey={`draft:${fileId}`} />
const editor = new Editor('file', {}, `draft:${fileId}`);

For CodeView, return the key from getEditStateKey(item). Keys are explicit application identities, are not inferred from cacheKey, and do not survive a reload.

You can clear an inactive draft after it is submitted or abandoned:

EditStateManager.clear('file', `draft:${fileId}`);

Persist across reloads

For local storage or another durable store, save the latest file and optional JSON-safe view state. Not all aspects of state are serializable (like undo history). An example of what is serializable:

function saveDraft(key, event) {
  localStorage.setItem(
    key,
    JSON.stringify({
      file: event.file,
      viewState: event.editor.getViewState(),
    })
  );
}

function loadDraft(key) {
  const value = localStorage.getItem(key);
  if (value == null) return undefined;

  try {
    return JSON.parse(value);
  } catch {
    localStorage.removeItem(key);
    return undefined;
  }
}

Pass the loaded file back as the component input. Restore selections and supported scroll offsets with a small initialState:

const draft = loadDraft(`draft:${fileId}`);

<File
  file={draft?.file ?? file}
  edit
  editorOptions={
    draft?.viewState
      ? {
          initialState: {
            type: 'file',
            editor: draft.viewState,
          },
        }
      : undefined
  }
/>;

This starts with fresh undo history. onEditChange does not fire for selection- or scroll-only changes, so capture getViewState() at an explicit save or navigation boundary when those changes matter. For a durable diff draft, persist the old file and latest new file, then reconstruct the diff after loading.

Other breaking changes

  • EditHistoryEntry and EditHistoryState now take the editor type before annotation metadata. Replace EditHistoryLineAnnotation<LAnnotation> with EditorLineAnnotation<'file' | 'file-diff', LAnnotation>, or use the specific editor type when the history belongs to only one component type.
  • Call the disposer returned by editor.edit(component) for normal completion. cleanUp('discard') still runs completion callbacks but never installs an accepted result. cleanUp('recycle') only suspends rendering for virtualization and does not complete the session.
  • Direct component attachEditor() and completeEditSession() calls are no longer supported. Do not call their internal replacements; use editor.edit(component) and its returned disposer.
  • TextDocument.clone() was removed. Construct a fresh TextDocument from its text when fresh history is appropriate.
  • We no longer automatically derive cacheKeys, so you must supply them now. Ensure they are always unique based on content.

What's Changed

New Contributors

Full Changelog: diffs-v1.3.6...diffs-v1.4.0