|
6 | 6 |
|
7 | 7 | import type { |
8 | 8 | Connection, DidOpenTextDocumentParams, DidChangeTextDocumentParams, DidCloseTextDocumentParams, TextDocumentsConfiguration, TextDocumentChangeEvent, |
9 | | - TextDocumentWillSaveEvent, RequestHandler, TextEdit, Event, WillSaveTextDocumentParams, CancellationToken, DidSaveTextDocumentParams |
| 9 | + TextDocumentWillSaveEvent, RequestHandler, TextEdit, Event, WillSaveTextDocumentParams, CancellationToken, DidSaveTextDocumentParams, |
| 10 | + NotebookCell, |
| 11 | + NotebookDocument, |
| 12 | + DocumentUri, |
| 13 | + NotificationHandler |
10 | 14 | } from 'vscode-languageserver'; |
11 | 15 | import { TextDocumentSyncKind, Disposable, Emitter } from 'vscode-languageserver'; |
12 | 16 | import type { URI } from '../utils/uri-utils.js'; |
13 | 17 | import { UriUtils } from '../utils/uri-utils.js'; |
| 18 | +// For some reason, this isn't exported by vscode-languageserver |
| 19 | +import type { NotebookDocumentChangeEvent } from 'vscode-languageserver/lib/common/notebook.js'; |
| 20 | + |
| 21 | +export type TextDocumentConnection = Pick<Connection, |
| 22 | +'onDidOpenTextDocument' | |
| 23 | +'onDidChangeTextDocument' | |
| 24 | +'onDidCloseTextDocument' | |
| 25 | +'onWillSaveTextDocument' | |
| 26 | +'onWillSaveTextDocumentWaitUntil' | |
| 27 | +'onDidSaveTextDocument' |
| 28 | +>; |
14 | 29 |
|
15 | 30 | /** |
16 | 31 | * A manager service that keeps track of all currently opened text documents. |
@@ -91,7 +106,7 @@ export interface TextDocuments<T extends { uri: string }> { |
91 | 106 | * |
92 | 107 | * @param connection The connection to listen on. |
93 | 108 | */ |
94 | | - listen(connection: Connection): Disposable; |
| 109 | + listen(connection: TextDocumentConnection): Disposable; |
95 | 110 | } |
96 | 111 |
|
97 | 112 | // Adapted from: |
@@ -253,3 +268,279 @@ export class NormalizedTextDocuments<T extends { uri: string }> implements TextD |
253 | 268 | return Disposable.create(() => { disposables.forEach(disposable => disposable.dispose()); }); |
254 | 269 | } |
255 | 270 | } |
| 271 | + |
| 272 | +export interface NotebookDocuments<T extends { uri: string }> { |
| 273 | + get cellTextDocuments(): TextDocuments<T>; |
| 274 | + getCellTextDocument(cell: NotebookCell): T | undefined; |
| 275 | + getNotebookDocument(uri: string | URI): NotebookDocument | undefined; |
| 276 | + getNotebookCell(uri: string | URI): NotebookCell | undefined; |
| 277 | + findNotebookDocumentForCell(cell: string | URI | NotebookCell): NotebookDocument | undefined; |
| 278 | + get onDidOpen(): Event<NotebookDocument>; |
| 279 | + get onDidSave(): Event<NotebookDocument>; |
| 280 | + get onDidChange(): Event<NotebookDocumentChangeEvent>; |
| 281 | + get onDidClose(): Event<NotebookDocument>; |
| 282 | + /** |
| 283 | + * Listens for `low level` notification on the given connection to |
| 284 | + * update the notebook documents managed by this instance. |
| 285 | + * |
| 286 | + * Please note that the connection only provides handlers not an event model. Therefore |
| 287 | + * listening on a connection will overwrite the following handlers on a connection: |
| 288 | + * `onDidOpenNotebookDocument`, `onDidChangeNotebookDocument`, `onDidSaveNotebookDocument`, |
| 289 | + * and `onDidCloseNotebookDocument`. |
| 290 | + * |
| 291 | + * @param connection The connection to listen on. |
| 292 | + */ |
| 293 | + listen(connection: Connection): Disposable; |
| 294 | +} |
| 295 | + |
| 296 | +export class NormalizedNotebookDocuments<T extends { uri: DocumentUri }> implements NotebookDocuments<T> { |
| 297 | + |
| 298 | + private readonly notebookDocuments = new Map<DocumentUri, NotebookDocument>(); |
| 299 | + private readonly notebookCellMap = new Map<DocumentUri, [NotebookCell, NotebookDocument]>(); |
| 300 | + |
| 301 | + private readonly _onDidOpen = new Emitter<NotebookDocument>(); |
| 302 | + private readonly _onDidSave = new Emitter<NotebookDocument>(); |
| 303 | + private readonly _onDidChange = new Emitter<NotebookDocumentChangeEvent>(); |
| 304 | + private readonly _onDidClose = new Emitter<NotebookDocument>(); |
| 305 | + |
| 306 | + private readonly _cellTextDocuments: TextDocuments<T>; |
| 307 | + |
| 308 | + constructor(configurationOrTextDocuments: TextDocumentsConfiguration<T> | TextDocuments<T>) { |
| 309 | + if ('listen' in configurationOrTextDocuments) { |
| 310 | + this._cellTextDocuments = configurationOrTextDocuments; |
| 311 | + } else { |
| 312 | + this._cellTextDocuments = new NormalizedTextDocuments<T>(configurationOrTextDocuments); |
| 313 | + } |
| 314 | + } |
| 315 | + |
| 316 | + get cellTextDocuments(): TextDocuments<T> { |
| 317 | + return this._cellTextDocuments; |
| 318 | + } |
| 319 | + |
| 320 | + getCellTextDocument(cell: NotebookCell): T | undefined { |
| 321 | + return this._cellTextDocuments.get(cell.document); |
| 322 | + } |
| 323 | + |
| 324 | + getNotebookDocument(uri: string | URI): NotebookDocument | undefined { |
| 325 | + return this.notebookDocuments.get(UriUtils.normalize(uri)); |
| 326 | + } |
| 327 | + |
| 328 | + getNotebookCell(uri: DocumentUri): NotebookCell | undefined { |
| 329 | + const value = this.notebookCellMap.get(uri); |
| 330 | + return value && value[0]; |
| 331 | + } |
| 332 | + |
| 333 | + findNotebookDocumentForCell(cell: string | URI | NotebookCell): NotebookDocument | undefined { |
| 334 | + const key = typeof cell === 'string' || 'scheme' in cell ? cell : cell.document; |
| 335 | + const value = this.notebookCellMap.get(UriUtils.normalize(key)); |
| 336 | + return value && value[1]; |
| 337 | + } |
| 338 | + |
| 339 | + get onDidOpen(): Event<NotebookDocument> { |
| 340 | + return this._onDidOpen.event; |
| 341 | + } |
| 342 | + |
| 343 | + get onDidSave(): Event<NotebookDocument> { |
| 344 | + return this._onDidSave.event; |
| 345 | + } |
| 346 | + |
| 347 | + get onDidChange(): Event<NotebookDocumentChangeEvent> { |
| 348 | + return this._onDidChange.event; |
| 349 | + } |
| 350 | + |
| 351 | + get onDidClose(): Event<NotebookDocument> { |
| 352 | + return this._onDidClose.event; |
| 353 | + } |
| 354 | + |
| 355 | + /** |
| 356 | + * Listens for `low level` notification on the given connection to |
| 357 | + * update the notebook documents managed by this instance. |
| 358 | + * |
| 359 | + * Please note that the connection only provides handlers not an event model. Therefore |
| 360 | + * listening on a connection will overwrite the following handlers on a connection: |
| 361 | + * `onDidOpenNotebookDocument`, `onDidChangeNotebookDocument`, `onDidSaveNotebookDocument`, |
| 362 | + * and `onDidCloseNotebookDocument`. |
| 363 | + * |
| 364 | + * @param connection The connection to listen on. |
| 365 | + */ |
| 366 | + listen(connection: Connection): Disposable { |
| 367 | + const cellTextDocumentConnection = new CellTextDocumentConnection(); |
| 368 | + const disposables: Disposable[] = []; |
| 369 | + |
| 370 | + disposables.push(this.cellTextDocuments.listen(cellTextDocumentConnection)); |
| 371 | + disposables.push(connection.notebooks.synchronization.onDidOpenNotebookDocument((params) => { |
| 372 | + const uri = UriUtils.normalize(params.notebookDocument.uri); |
| 373 | + this.notebookDocuments.set(uri, params.notebookDocument); |
| 374 | + for (const cellTextDocument of params.cellTextDocuments) { |
| 375 | + cellTextDocumentConnection.openTextDocument({ textDocument: cellTextDocument }); |
| 376 | + } |
| 377 | + this.updateCellMap(params.notebookDocument); |
| 378 | + this._onDidOpen.fire(params.notebookDocument); |
| 379 | + })); |
| 380 | + disposables.push(connection.notebooks.synchronization.onDidChangeNotebookDocument((params) => { |
| 381 | + const uri = UriUtils.normalize(params.notebookDocument.uri); |
| 382 | + const notebookDocument = this.notebookDocuments.get(uri); |
| 383 | + if (notebookDocument === undefined) { |
| 384 | + return; |
| 385 | + } |
| 386 | + notebookDocument.version = params.notebookDocument.version; |
| 387 | + const oldMetadata = notebookDocument.metadata; |
| 388 | + let metadataChanged: boolean = false; |
| 389 | + const change = params.change; |
| 390 | + if (change.metadata !== undefined) { |
| 391 | + metadataChanged = true; |
| 392 | + notebookDocument.metadata = change.metadata; |
| 393 | + } |
| 394 | + |
| 395 | + const opened: DocumentUri[] = []; |
| 396 | + const closed: DocumentUri[] = []; |
| 397 | + const data: Required<Required<Required<NotebookDocumentChangeEvent>['cells']>['changed']>['data'] = []; |
| 398 | + const text: DocumentUri[] = []; |
| 399 | + if (change.cells !== undefined) { |
| 400 | + const changedCells = change.cells; |
| 401 | + if (changedCells.structure !== undefined) { |
| 402 | + const array = changedCells.structure.array; |
| 403 | + notebookDocument.cells.splice(array.start, array.deleteCount, ...(array.cells !== undefined ? array.cells : [])); |
| 404 | + // Additional open cell text documents. |
| 405 | + if (changedCells.structure.didOpen !== undefined) { |
| 406 | + for (const open of changedCells.structure.didOpen) { |
| 407 | + cellTextDocumentConnection.openTextDocument({ textDocument: open }); |
| 408 | + opened.push(open.uri); |
| 409 | + } |
| 410 | + } |
| 411 | + // Additional closed cell test documents. |
| 412 | + if (changedCells.structure.didClose) { |
| 413 | + for (const close of changedCells.structure.didClose) { |
| 414 | + cellTextDocumentConnection.closeTextDocument({ textDocument: close }); |
| 415 | + closed.push(close.uri); |
| 416 | + } |
| 417 | + } |
| 418 | + } |
| 419 | + if (changedCells.data !== undefined) { |
| 420 | + const cellUpdates: Map<string, NotebookCell> = new Map(changedCells.data.map(cell => [cell.document, cell])); |
| 421 | + for (let i = 0; i <= notebookDocument.cells.length; i++) { |
| 422 | + const change = cellUpdates.get(notebookDocument.cells[i].document); |
| 423 | + if (change !== undefined) { |
| 424 | + const old = notebookDocument.cells.splice(i, 1, change); |
| 425 | + data.push({ old: old[0], new: change }); |
| 426 | + cellUpdates.delete(change.document); |
| 427 | + if (cellUpdates.size === 0) { |
| 428 | + break; |
| 429 | + } |
| 430 | + } |
| 431 | + } |
| 432 | + } |
| 433 | + if (changedCells.textContent !== undefined) { |
| 434 | + for (const cellTextDocument of changedCells.textContent) { |
| 435 | + cellTextDocumentConnection.changeTextDocument({ textDocument: cellTextDocument.document, contentChanges: cellTextDocument.changes }); |
| 436 | + text.push(cellTextDocument.document.uri); |
| 437 | + } |
| 438 | + } |
| 439 | + } |
| 440 | + |
| 441 | + // Update internal data structure. |
| 442 | + this.updateCellMap(notebookDocument); |
| 443 | + |
| 444 | + const changeEvent: NotebookDocumentChangeEvent = { notebookDocument }; |
| 445 | + if (metadataChanged) { |
| 446 | + changeEvent.metadata = { old: oldMetadata, new: notebookDocument.metadata }; |
| 447 | + } |
| 448 | + |
| 449 | + const added: NotebookCell[] = []; |
| 450 | + for (const open of opened) { |
| 451 | + added.push(this.getNotebookCell(open)!); |
| 452 | + } |
| 453 | + const removed: NotebookCell[] = []; |
| 454 | + for (const close of closed) { |
| 455 | + removed.push(this.getNotebookCell(close)!); |
| 456 | + } |
| 457 | + const textContent: NotebookCell[] = []; |
| 458 | + for (const change of text) { |
| 459 | + textContent.push(this.getNotebookCell(change)!); |
| 460 | + } |
| 461 | + if (added.length > 0 || removed.length > 0 || data.length > 0 || textContent.length > 0) { |
| 462 | + changeEvent.cells = { added, removed, changed: { data, textContent } }; |
| 463 | + } |
| 464 | + if (changeEvent.metadata !== undefined || changeEvent.cells !== undefined) { |
| 465 | + this._onDidChange.fire(changeEvent); |
| 466 | + } |
| 467 | + })); |
| 468 | + disposables.push(connection.notebooks.synchronization.onDidSaveNotebookDocument((params) => { |
| 469 | + const notebookDocument = this.getNotebookDocument(params.notebookDocument.uri); |
| 470 | + if (notebookDocument === undefined) { |
| 471 | + return; |
| 472 | + } |
| 473 | + this._onDidSave.fire(notebookDocument); |
| 474 | + })); |
| 475 | + disposables.push(connection.notebooks.synchronization.onDidCloseNotebookDocument((params) => { |
| 476 | + const uri = UriUtils.normalize(params.notebookDocument.uri); |
| 477 | + const notebookDocument = this.notebookDocuments.get(uri); |
| 478 | + if (notebookDocument === undefined) { |
| 479 | + return; |
| 480 | + } |
| 481 | + this._onDidClose.fire(notebookDocument); |
| 482 | + for (const cellTextDocument of params.cellTextDocuments) { |
| 483 | + cellTextDocumentConnection.closeTextDocument({ textDocument: cellTextDocument }); |
| 484 | + } |
| 485 | + this.notebookDocuments.delete(uri); |
| 486 | + for (const cell of notebookDocument.cells) { |
| 487 | + this.notebookCellMap.delete(cell.document); |
| 488 | + } |
| 489 | + })); |
| 490 | + return Disposable.create(() => { disposables.forEach(disposable => disposable.dispose()); }); |
| 491 | + } |
| 492 | + |
| 493 | + private updateCellMap(notebookDocument: NotebookDocument): void { |
| 494 | + for (const cell of notebookDocument.cells) { |
| 495 | + this.notebookCellMap.set(cell.document, [cell, notebookDocument]); |
| 496 | + } |
| 497 | + } |
| 498 | +} |
| 499 | + |
| 500 | +class CellTextDocumentConnection implements TextDocumentConnection { |
| 501 | + |
| 502 | + private static readonly NULL_DISPOSE = Object.freeze({ dispose: () => { } }); |
| 503 | + |
| 504 | + private openHandler: NotificationHandler<DidOpenTextDocumentParams> | undefined; |
| 505 | + private changeHandler: NotificationHandler<DidChangeTextDocumentParams> | undefined; |
| 506 | + private closeHandler: NotificationHandler<DidCloseTextDocumentParams> | undefined; |
| 507 | + |
| 508 | + onDidOpenTextDocument(handler: NotificationHandler<DidOpenTextDocumentParams>): Disposable { |
| 509 | + this.openHandler = handler; |
| 510 | + return Disposable.create(() => { this.openHandler = undefined; }); |
| 511 | + } |
| 512 | + |
| 513 | + openTextDocument(params: DidOpenTextDocumentParams): void { |
| 514 | + this.openHandler && this.openHandler(params); |
| 515 | + } |
| 516 | + |
| 517 | + onDidChangeTextDocument(handler: NotificationHandler<DidChangeTextDocumentParams>): Disposable { |
| 518 | + this.changeHandler = handler; |
| 519 | + return Disposable.create(() => { this.changeHandler = handler; }); |
| 520 | + } |
| 521 | + |
| 522 | + changeTextDocument(params: DidChangeTextDocumentParams): void { |
| 523 | + this.changeHandler && this.changeHandler(params); |
| 524 | + } |
| 525 | + |
| 526 | + onDidCloseTextDocument(handler: NotificationHandler<DidCloseTextDocumentParams>): Disposable { |
| 527 | + this.closeHandler = handler; |
| 528 | + return Disposable.create(() => { this.closeHandler = undefined; }); |
| 529 | + } |
| 530 | + |
| 531 | + closeTextDocument(params: DidCloseTextDocumentParams): void { |
| 532 | + this.closeHandler && this.closeHandler(params); |
| 533 | + } |
| 534 | + |
| 535 | + onWillSaveTextDocument(): Disposable { |
| 536 | + return CellTextDocumentConnection.NULL_DISPOSE; |
| 537 | + } |
| 538 | + |
| 539 | + onWillSaveTextDocumentWaitUntil(): Disposable { |
| 540 | + return CellTextDocumentConnection.NULL_DISPOSE; |
| 541 | + } |
| 542 | + |
| 543 | + onDidSaveTextDocument(): Disposable { |
| 544 | + return CellTextDocumentConnection.NULL_DISPOSE; |
| 545 | + } |
| 546 | +} |
0 commit comments