feat(notes): show block locks and presence, conflict copies with accept/discard, undo/redo
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m21s

Phase 3 of the collaboration plan:
- Blocks held by someone else show a lock with the name and are read-only;
  Enter in such a block adds a new block below. Delete, merge, indent and
  drag are not started when a involved block (or one in its branch) is
  locked; a short notice explains why (fixes FLAW-25).
- Presence: the editor header lists who else has the note open.
- Conflict copies are highlighted ("Konflikt – Version von …") with
  Übernehmen (replaces the original or the title) and Verwerfen. Both check
  the locks of the original and of the copy first.
- Undo/redo of own changes (Ctrl/Cmd+Z, Ctrl+Shift+Z, Ctrl+Y, beforeinput
  history events): typing is grouped per block until a 1 s pause, up to 200
  steps per tab; a step is skipped with a notice if someone else changed or
  holds the block meanwhile. New clearConflict props patch on the server.
- New blocks get focus synchronously on mount (fixes FLAW-26).
- Fix: after reopening a note from local storage the engine polled every
  30 s instead of 3 s until the next change, because the delay was decided
  before the first response told it the note is shared.
- Tests: undo/conflict engine tests, lock/undo/conflict UI tests and
  e2e/lock.e2e.mjs (two browsers); all three browser tests pass against a
  freshly built image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 13:44:27 +01:00
1 parent 08d9b2dc77
commit aa397c18cd
20 files changed
+1060 -38

No files matched your search

@@ -1,5 +1,5 @@
import 'fake-indexeddb/auto';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import type { DocumentBlock } from '../../types';
import { DocSyncEngine, formBatch } from '../engine';
import { applyOp, cloneDoc, Doc, emptyDoc, Op, Result } from '../model';
@@ -254,6 +254,33 @@ describe('DocSyncEngine', () => {
expect(serverContents(server)).toEqual(['Start']);
});
it('polls every 3 s right after reopening a shared note from local storage', async () => {
vi.useFakeTimers();
try {
const server = new FakeServer();
const net = new FakeNet(server);
let calls = 0;
const sync = net.sync.bind(net);
net.sync = async (...args: Parameters<typeof sync>) => {
calls++;
return sync(...args);
};
// Opened before: the note is reopened from local storage (like after a reload)
const storage = new MemoryStorage();
const first = new DocSyncEngine('doc', { transport: net, storage, manual: true });
await first.start();
first.stop();
const engine = new DocSyncEngine('doc', { transport: net, storage });
await engine.start();
const afterStart = calls;
await vi.advanceTimersByTimeAsync(3_500);
expect(calls).toBeGreaterThan(afterStart); // not only after 30 s
engine.stop();
} finally {
vi.useRealTimers();
}
});
it('formBatch keeps the last text per block with the base of the first', () => {
const batch = formBatch([
{ opId: '1', kind: 'set_content', blockId: 'a', content: 'x', baseVersion: 5 },
@@ -0,0 +1,184 @@
import { describe, it, expect } from 'vitest';
import type { DocumentBlock } from '../../types';
import { DocSyncEngine } from '../engine';
import { applyOp, Doc, emptyDoc, Op, Result } from '../model';
import { MemoryStorage } from '../persist';
import { LogEntry, SyncRequest, Transport, TransportError } from '../transport';
/** Minimal stand-in server (same rules as the Go server, plus configurable leases of others). */
class Server {
doc: Doc = emptyDoc();
log: LogEntry[] = [];
results = new Map<string, Result>();
othersLeases: { blockId: string; userId: string; name: string }[] = [];
constructor(blocks: [string, string][]) {
blocks.forEach(([id, content], i) => this.apply({ opId: `seed${i}`, kind: 'insert', blockId: id, parentId: '', orderKey: `a${i}`, content }, 'seed'));
}
apply(op: Op, by: string): Result {
const known = this.results.get(op.opId);
if (known) return known;
const { effects, result } = applyOp(this.doc, op, by);
for (const e of effects) this.log.push({ seq: this.log.length + 1, userId: by, clientId: by, opId: op.opId, effect: e });
if (effects.length) result.seq = this.log.length;
this.results.set(op.opId, result);
return result;
}
transport(): Transport {
return {
snapshot: async () => ({
docId: 'd',
title: this.doc.title,
titleVersion: this.doc.titleVersion,
headSeq: this.log.length,
blocks: Array.from(this.doc.blocks.values()).map((b) => ({ ...b, props: { ...b.props } })),
role: 'owner',
shared: true,
}),
sync: async (_id: string, body: SyncRequest) => {
if (!body) throw new TransportError('x', 0);
const results = body.ops.map((op) => this.apply(op, body.clientId));
return {
results,
ops: this.log.filter((e) => e.seq > body.lastSeq),
headSeq: this.log.length,
hasMore: false,
role: 'owner',
shared: true,
sessions: 2,
presence: [],
leases: { granted: [], denied: [], others: this.othersLeases },
};
},
leave: async () => {},
};
}
}
let clock = 1_000_000;
async function engineFor(server: Server) {
const e = new DocSyncEngine('d', { transport: server.transport(), storage: new MemoryStorage(), manual: true, now: () => clock });
await e.start();
return e;
}
const flat = (e: DocSyncEngine) => e.getState().blocks;
const texts = (e: DocSyncEngine) => flat(e).map((b) => b.content);
function edit(e: DocSyncEngine, fn: (b: DocumentBlock[]) => DocumentBlock[]) {
e.applyFlat(fn(flat(e).map((b) => ({ ...b }))));
}
describe('undo/redo', () => {
it('groups typing in one block and reverts it in one step', async () => {
const server = new Server([['a', 'Hallo']]);
const e = await engineFor(server);
edit(e, (b) => [{ ...b[0], content: 'Hallo W' }]);
clock += 300;
edit(e, (b) => [{ ...b[0], content: 'Hallo Welt' }]);
expect(e.undo().ok).toBe(true);
expect(texts(e)).toEqual(['Hallo']);
expect(e.getState().canUndo).toBe(false);
expect(e.redo().ok).toBe(true);
expect(texts(e)).toEqual(['Hallo Welt']);
await e.syncNow();
expect(server.doc.blocks.get('a')!.content).toBe('Hallo Welt');
});
it('separates typing steps after a pause', async () => {
const server = new Server([['a', '']]);
const e = await engineFor(server);
edit(e, (b) => [{ ...b[0], content: 'Eins' }]);
clock += 2000;
edit(e, (b) => [{ ...b[0], content: 'Eins Zwei' }]);
e.undo();
expect(texts(e)).toEqual(['Eins']);
});
it('undoes inserting and deleting blocks', async () => {
const server = new Server([['a', 'Erster']]);
const e = await engineFor(server);
edit(e, (b) => [...b, { id: 'n', type: 'bullet', content: 'Neu', level: 0 }]);
clock += 2000;
edit(e, (b) => b.filter((x) => x.id !== 'a'));
expect(texts(e)).toEqual(['Neu']);
e.undo();
expect(texts(e)).toEqual(['Erster', 'Neu']);
e.undo();
expect(texts(e)).toEqual(['Erster']);
await e.syncNow();
expect(server.doc.blocks.get('a')!.deleted).toBe(false);
expect(server.doc.blocks.get('n')!.deleted).toBe(true);
});
it('skips undo when someone else changed the block in between', async () => {
const server = new Server([['a', 'Start']]);
const e = await engineFor(server);
edit(e, (b) => [{ ...b[0], content: 'Meins' }]);
await e.syncNow();
server.apply({ opId: 'other', kind: 'set_content', blockId: 'a', baseVersion: 2, content: 'Fremd' }, 'other');
await e.syncNow();
const res = e.undo();
expect(res.ok).toBe(false);
expect(res.message).toMatch(/jemand anderem/);
expect(texts(e)).toEqual(['Fremd']);
});
it('skips undo on a block someone else is editing', async () => {
const server = new Server([['a', 'Start']]);
const e = await engineFor(server);
edit(e, (b) => [{ ...b[0], content: 'Meins' }]);
await e.syncNow();
server.othersLeases = [{ blockId: 'a', userId: 'u2', name: 'Anna' }];
await e.syncNow();
const res = e.undo();
expect(res.ok).toBe(false);
expect(res.message).toMatch(/Anna/);
expect(texts(e)).toEqual(['Meins']);
});
});
describe('conflict copies', () => {
async function withConflict() {
const server = new Server([['a', 'Start']]);
const e = await engineFor(server);
server.apply({ opId: 'first', kind: 'set_content', blockId: 'a', baseVersion: 1, content: 'Gewinner' }, 'other');
server.apply({ opId: 'second', kind: 'set_content', blockId: 'a', baseVersion: 1, content: 'Verlierer' }, 'mine');
await e.syncNow();
const copy = flat(e).find((b) => b.conflictOf === 'a')!;
return { server, e, copy };
}
it('accepting replaces the original text and removes the copy', async () => {
const { server, e, copy } = await withConflict();
expect(e.resolveConflict(copy.id, true).ok).toBe(true);
expect(texts(e)).toEqual(['Verlierer']);
await e.syncNow();
expect(server.doc.blocks.get('a')!.content).toBe('Verlierer');
expect(server.doc.blocks.get(copy.id)!.deleted).toBe(true);
e.undo();
expect(texts(e)).toEqual(['Gewinner', 'Verlierer']);
});
it('is not started while someone else holds the copy', async () => {
const { server, e, copy } = await withConflict();
server.othersLeases = [{ blockId: copy.id, userId: 'u2', name: 'Ben' }];
await e.syncNow();
const res = e.resolveConflict(copy.id, true);
expect(res.ok).toBe(false);
expect(res.message).toMatch(/Ben/);
expect(texts(e)).toEqual(['Gewinner', 'Verlierer']);
});
it('discarding removes only the copy', async () => {
const { e, copy } = await withConflict();
e.resolveConflict(copy.id, false);
expect(texts(e)).toEqual(['Gewinner']);
});
it('a copy whose original is gone becomes a normal block', async () => {
const { server, e, copy } = await withConflict();
server.apply({ opId: 'del', kind: 'delete', blockId: 'a' }, 'other');
await e.syncNow();
e.resolveConflict(copy.id, true);
expect(flat(e).find((b) => b.id === copy.id)?.conflictOf).toBeUndefined();
});
});