This commit is contained in:
Andras Schmelczer 2026-09-23 08:23:17 +01:00
commit 4c002f9acd

View file

@ -23,6 +23,7 @@ export class CursorTracker {
>();
private readonly updateLock = new Lock();
private sessionGeneration = 0;
private knownRemoteCursors: (ClientCursors & {
upToDateness: DocumentUpToDateness;
@ -31,6 +32,10 @@ export class CursorTracker {
private lastLocalCursorState: DocumentWithCursors[] = [];
private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] =
[];
private localCursors: Record<string, CursorSpan[]> = {};
private localCursorDocuments: Record<string, string> = {};
private cursorTimer?: ReturnType<typeof setInterval>;
private readonly sendLock = new Lock();
public constructor(
private readonly database: Database,
@ -38,9 +43,31 @@ export class CursorTracker {
private readonly fileOperations: FileOperations,
private readonly fileChangeNotifier: FileChangeNotifier
) {
this.webSocketManager.onWebSocketStatusChanged.add((connected) => {
this.sessionGeneration++;
clearInterval(this.cursorTimer);
this.lastLocalCursorState = [];
this.lastLocalCursorStateWithoutDirtyDocuments = [];
if (!connected) {
this.knownRemoteCursors = [];
this.onRemoteCursorsUpdated.trigger([]);
return;
}
const refresh = () => {
void this.publishLocalCursors(true).catch(() => {
// A concurrent move/deletion can invalidate an editor view;
// the next notification or heartbeat retries the snapshot.
});
};
refresh();
this.cursorTimer = setInterval(refresh, 15_000);
this.cursorTimer.unref?.();
});
this.webSocketManager.onRemoteCursorsUpdateReceived.add(
async (clientCursors) => {
const generation = this.sessionGeneration;
await this.updateLock.withLock(async () => {
if (generation !== this.sessionGeneration) return;
// The latest message will contain all active clients, so we can delete the ones
// from the local list which are no longer active.
const allIds = new Set(
@ -51,11 +78,7 @@ export class CursorTracker {
allIds.has(c.deviceId)
);
for (const cursor of clientCursors.filter((client) =>
client.documentsWithCursors.every(
(doc) => doc.vault_update_id != null
)
)) {
for (const cursor of clientCursors) {
updatedKnownRemoteCursors.push({
...cursor,
upToDateness:
@ -63,17 +86,20 @@ export class CursorTracker {
});
}
if (generation !== this.sessionGeneration) return;
this.knownRemoteCursors = updatedKnownRemoteCursors;
this.onRemoteCursorsUpdated.trigger(
this.getRelevantAndPruneKnownClientCursors()
);
});
this.onRemoteCursorsUpdated.trigger(
this.getRelevantAndPruneKnownClientCursors()
);
}
);
this.fileChangeNotifier.onFileChanged.add(async (relativePath) => {
const generation = this.sessionGeneration;
await this.publishLocalCursors(true);
const cursors = await this.updateLock.withLock(async () => {
if (generation !== this.sessionGeneration) return [];
for (const clientCursor of this.knownRemoteCursors) {
if (
clientCursor.documentsWithCursors.some(
@ -88,10 +114,13 @@ export class CursorTracker {
await this.getDocumentsUpToDateness(clientCursor);
}
}
return this.getRelevantAndPruneKnownClientCursors();
return generation === this.sessionGeneration
? this.getRelevantAndPruneKnownClientCursors()
: [];
});
this.onRemoteCursorsUpdated.trigger(cursors);
if (generation === this.sessionGeneration)
this.onRemoteCursorsUpdated.trigger(cursors);
});
}
@ -101,71 +130,106 @@ export class CursorTracker {
public async sendLocalCursorsToServer(
documentToCursors: Record<RelativePath, CursorSpan[]>
): Promise<void> {
const documentsWithCursors: DocumentWithCursors[] = [];
this.localCursors = structuredClone(documentToCursors);
this.localCursorDocuments = {};
await this.publishLocalCursors(false);
}
for (const [relativePath, cursors] of Object.entries(
documentToCursors
)) {
const record =
this.database.getLatestDocumentByRelativePath(relativePath);
private async publishLocalCursors(force: boolean): Promise<void> {
const generation = this.sessionGeneration;
await this.sendLock.withLock(async () => {
if (generation !== this.sessionGeneration) return;
const documentsWithCursors: DocumentWithCursors[] = [];
if (!record) {
continue; // Let's wait for the file to be created before sending cursors
for (const [editorPath, cursors] of Object.entries(
this.localCursors
)) {
const id = this.localCursorDocuments[editorPath];
const record = id
? this.database.getDocumentByDocumentId(id)
: this.database.getLatestDocumentByRelativePath(editorPath);
if (!record) {
continue; // Let's wait for the file to be created before sending cursors
}
this.localCursorDocuments[editorPath] = record.documentId;
const { relativePath } = record;
if (!record.metadata) {
continue; // this is a new document, no need to sync the cursors
}
documentsWithCursors.push({
relative_path: relativePath,
document_id: record.documentId,
vault_update_id: record.metadata.parentVersionId,
cursors: cursors.map(({ start, end }) => ({
start: Math.min(start, end),
end: Math.max(start, end)
})) // the client might send directional selections
});
}
if (!record.metadata) {
continue; // this is a new document, no need to sync the cursors
if (
!force &&
JSON.stringify(this.lastLocalCursorState) ===
JSON.stringify(documentsWithCursors)
) {
// Caching step to avoid reading the edited files all the time
return;
}
this.lastLocalCursorState = structuredClone(documentsWithCursors);
for (const doc of documentsWithCursors) {
let readContent: Uint8Array;
try {
readContent = await this.fileOperations.read(
doc.relative_path
);
} catch {
doc.vault_update_id = null;
continue;
}
const digest = await hash(readContent);
const record = this.database.getLatestDocumentByRelativePath(
doc.relative_path
);
if (
record?.documentId !== doc.document_id ||
record.relativePath !== doc.relative_path ||
record.metadata?.parentVersionId !== doc.vault_update_id ||
record.metadata.hash !== digest
) {
doc.vault_update_id = null;
}
}
documentsWithCursors.push({
relative_path: relativePath,
document_id: record.documentId,
vault_update_id: record.metadata.parentVersionId,
cursors: cursors.map(({ start, end }) => ({
start: Math.min(start, end),
end: Math.max(start, end)
})) // the client might send directional selections
});
}
if (
JSON.stringify(this.lastLocalCursorState) ===
JSON.stringify(documentsWithCursors)
) {
// Caching step to avoid reading the edited files all the time
return;
}
this.lastLocalCursorState = documentsWithCursors;
for (const doc of documentsWithCursors) {
const readContent = await this.fileOperations.read(
doc.relative_path
);
const record = this.database.getLatestDocumentByRelativePath(
doc.relative_path
);
if (record?.metadata?.hash !== (await hash(readContent))) {
doc.vault_update_id = null;
if (generation !== this.sessionGeneration) return;
if (
!force &&
JSON.stringify(
this.lastLocalCursorStateWithoutDirtyDocuments
) === JSON.stringify(documentsWithCursors)
) {
return;
}
}
if (
JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) ===
JSON.stringify(documentsWithCursors)
) {
return;
}
this.lastLocalCursorStateWithoutDirtyDocuments =
documentsWithCursors;
this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors;
this.webSocketManager.updateLocalCursors({ documentsWithCursors });
this.webSocketManager.updateLocalCursors({ documentsWithCursors });
});
}
public reset(): void {
this.sessionGeneration++;
clearInterval(this.cursorTimer);
this.knownRemoteCursors = [];
this.onRemoteCursorsUpdated.trigger([]);
this.lastLocalCursorState = [];
this.lastLocalCursorStateWithoutDirtyDocuments = [];
this.updateLock.reset();
// Keep serialization intact while old reads drain. A generation fence
// discards both active and queued work from the retired connection.
}
private getRelevantAndPruneKnownClientCursors(): MaybeOutdatedClientCursors[] {
@ -245,6 +309,8 @@ export class CursorTracker {
private async getDocumentUpToDateness(
document: DocumentWithCursors
): Promise<DocumentUpToDateness> {
if (document.vault_update_id === null)
return DocumentUpToDateness.Prior;
const record = this.database.getDocumentByDocumentId(
document.document_id
);
@ -255,13 +321,11 @@ export class CursorTracker {
}
if (
(record.metadata?.parentVersionId ?? 0) <
(document.vault_update_id ?? 0)
(record.metadata?.parentVersionId ?? 0) < document.vault_update_id
) {
return DocumentUpToDateness.Later;
} else if (
(document.vault_update_id ?? 0) <
(record.metadata?.parentVersionId ?? 0)
document.vault_update_id < (record.metadata?.parentVersionId ?? 0)
) {
// the document of the cursor must be from the past
return DocumentUpToDateness.Prior;
@ -276,7 +340,14 @@ export class CursorTracker {
return DocumentUpToDateness.Prior;
}
return record.metadata?.hash === (await hash(currentContent))
const contentHash = await hash(currentContent);
const current = this.database.getDocumentByDocumentId(
document.document_id
);
return current?.metadata?.parentVersionId ===
document.vault_update_id &&
current.relativePath === record.relativePath &&
current.metadata.hash === contentHash
? DocumentUpToDateness.UpToDate
: DocumentUpToDateness.Prior;
}