wip
This commit is contained in:
parent
31a5bac054
commit
4c002f9acd
1 changed files with 14543 additions and 4367 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue