| author | |
| committer | |
| log | 6f72c75c17126db8f199d81e8e58650a45ed131e |
| tree | 2653b4417a73b91e6a53854e9b5e734609935d77 |
| parent | cccf69cbca01bafa974afa80c256a86437141d47 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ |
closes #94
Assisted-by: Claude:claude-fable-58 files changed, 943 insertions(+), 151 deletions(-)
lib/log.test.ts+390| ... | ... | @@ -524,6 +524,394 @@ describe("log widgets", () => { |
| 524 | 524 | host.cancel(); |
| 525 | 525 | }); |
| 526 | 526 | |
| 527 | test("empty string keeps the widget alive", () => { | |
| 528 | const host = new testing.MockScreen(); | |
| 529 | let text = ""; | |
| 530 | const w = UNWRAP(host.startWidget({ format: () => text })); | |
| 531 | // an empty frame draws nothing, but the widget is not detached | |
| 532 | host.expectFrame(0, { merged: "" }); | |
| 533 | text = "now!"; | |
| 534 | w.redraw(); | |
| 535 | host.expectFrame(0, { | |
| 536 | stderr: testing.MockScreen.sync(["now!\n"]), | |
| 537 | }); | |
| 538 | text = ""; | |
| 539 | w.redraw(); | |
| 540 | host.expectFrame(0, { | |
| 541 | stderr: testing.MockScreen.sync([ | |
| 542 | ansi.cursorUp(1), | |
| 543 | ansi.clearFullLine, | |
| 544 | ]), | |
| 545 | }); | |
| 546 | w.stop(); | |
| 547 | host.expectFrame(0, { merged: "" }); | |
| 548 | host.cancel(); | |
| 549 | }); | |
| 550 | ||
| 551 | test("leading blank line in widget output", () => { | |
| 552 | const host = new testing.MockScreen(); | |
| 553 | using _ = host.startWidget({ format: () => "\nfoo" }); | |
| 554 | host.expectFrame(0, { | |
| 555 | stderr: testing.MockScreen.sync([ | |
| 556 | "\n", | |
| 557 | "foo\n", | |
| 558 | ]), | |
| 559 | }); | |
| 560 | host.cancel(); | |
| 561 | }); | |
| 562 | ||
| 563 | test("empty widget frame with a mid-line draw lock release", () => { | |
| 564 | // regression: this combination used to crash an assertion, because an | |
| 565 | // empty first widget line was treated as "no widgets on screen" | |
| 566 | const host = new testing.MockScreen(); | |
| 567 | let text = ""; | |
| 568 | const w = UNWRAP(host.startWidget({ format: () => ({ text }) })); | |
| 569 | host.expectFrame(0, { merged: "" }); | |
| 570 | const lock = host.getDrawLock("short"); | |
| 571 | lock.release("cursor-middle-of-line"); | |
| 572 | host.expectFrame(0, { merged: "" }); | |
| 573 | // the foreign partial line is respected once the widget renders | |
| 574 | text = "hi"; | |
| 575 | w.redraw(); | |
| 576 | host.expectFrame(0, { | |
| 577 | stderr: ansi.cursorSave + "\n" + testing.MockScreen.sync(["hi\n"]), | |
| 578 | }); | |
| 579 | host.cancel(); | |
| 580 | }); | |
| 581 | ||
| 582 | test("temporary unlock is used when available", () => { | |
| 583 | const host = new testing.MockScreen({ temporaryUnlocking: true }); | |
| 584 | using _ = host.startWidget({ format: () => "w" }); | |
| 585 | host.expectFrame(0, { | |
| 586 | stderr: testing.MockScreen.sync(["w\n"]), | |
| 587 | }); | |
| 588 | ASSERT(host.hasTerminalLock === "locked"); | |
| 589 | const lock = host.getDrawLock("short"); | |
| 590 | ASSERT(host.hasTerminalLock === "temporary-unlock"); | |
| 591 | host.expectFrame(null, { | |
| 592 | stderr: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine, | |
| 593 | }); | |
| 594 | lock.release("cursor-start-of-line"); | |
| 595 | ASSERT(host.hasTerminalLock === "locked"); | |
| 596 | host.expectFrame(0, { | |
| 597 | stderr: "w\n" + ansi.syncEnd, | |
| 598 | }); | |
| 599 | host.cancel(); | |
| 600 | }); | |
| 601 | ||
| 602 | test("cursor restore is not replayed for stale saves", () => { | |
| 603 | const host = new testing.MockScreen(); | |
| 604 | using _ = host.startWidget({ format: () => "w" }); | |
| 605 | host.expectFrame(0, { | |
| 606 | stderr: testing.MockScreen.sync(["w\n"]), | |
| 607 | }); | |
| 608 | ||
| 609 | // a foreign partial line forces a cursor save | |
| 610 | { | |
| 611 | const lock = host.getDrawLock("short"); | |
| 612 | host.expectFrame(null, { | |
| 613 | merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine, | |
| 614 | }); | |
| 615 | lock.release("cursor-middle-of-line"); | |
| 616 | host.expectFrame(0, { | |
| 617 | merged: ansi.cursorSave + "\n" + "w\n" + ansi.syncEnd, | |
| 618 | }); | |
| 619 | } | |
| 620 | ||
| 621 | // clearing the widget restores the saved cursor, exactly once | |
| 622 | { | |
| 623 | const lock = host.getDrawLock("short"); | |
| 624 | host.expectFrame(null, { | |
| 625 | merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine | |
| 626 | + ansi.cursorRestore, | |
| 627 | }); | |
| 628 | lock.release("cursor-start-of-line"); | |
| 629 | host.expectFrame(0, { | |
| 630 | merged: "w\n" + ansi.syncEnd, | |
| 631 | }); | |
| 632 | } | |
| 633 | ||
| 634 | // regression: the stale save register must not be restored again | |
| 635 | { | |
| 636 | const lock = host.getDrawLock("short"); | |
| 637 | host.expectFrame(null, { | |
| 638 | merged: ansi.syncStart + ansi.cursorUp(1) + ansi.clearFullLine, | |
| 639 | }); | |
| 640 | lock.release("cursor-start-of-line"); | |
| 641 | host.expectFrame(0, { | |
| 642 | merged: "w\n" + ansi.syncEnd, | |
| 643 | }); | |
| 644 | } | |
| 645 | host.cancel(); | |
| 646 | }); | |
| 647 | ||
| 648 | test("log lines wider than the terminal", () => { | |
| 649 | const host = new testing.MockScreen(); | |
| 650 | host.columns = 10; | |
| 651 | using _ = host.startWidget({ format: () => "w1\nw2" }); | |
| 652 | host.expectFrame(0, { | |
| 653 | stderr: testing.MockScreen.sync(["w1\nw2\n"]), | |
| 654 | }); | |
| 655 | // a 15 cell line occupies two rows, so both widget rows get cleared | |
| 656 | host.writeOutput("123456789012345\n"); | |
| 657 | host.expectFrame(0, { | |
| 658 | merged: testing.MockScreen.sync([ | |
| 659 | ansi.cursorUp(1), | |
| 660 | ansi.clearFullLine, | |
| 661 | ansi.cursorUp(1), | |
| 662 | ansi.clearFullLine, | |
| 663 | "123456789012345\n", | |
| 664 | "w1\n", | |
| 665 | "w2\n", | |
| 666 | ]), | |
| 667 | }); | |
| 668 | host.cancel(); | |
| 669 | }); | |
| 670 | ||
| 671 | test("partial line wider than the terminal", () => { | |
| 672 | const host = new testing.MockScreen(); | |
| 673 | host.columns = 10; | |
| 674 | using _ = host.startWidget({ format: () => "w1\nw2" }); | |
| 675 | host.expectFrame(0, { | |
| 676 | stderr: testing.MockScreen.sync(["w1\nw2\n"]), | |
| 677 | }); | |
| 678 | // 14 cells: one wrapped row plus a partial row at column 4 | |
| 679 | host.writeOutput("12345678901234"); | |
| 680 | host.expectFrame(0, { | |
| 681 | merged: testing.MockScreen.sync([ | |
| 682 | ansi.cursorUp(1), | |
| 683 | ansi.clearFullLine, | |
| 684 | ansi.cursorUp(1), | |
| 685 | ansi.clearFullLine, | |
| 686 | "12345678901234", | |
| 687 | "\n", | |
| 688 | "w1\n", | |
| 689 | "w2\n", | |
| 690 | ]), | |
| 691 | }); | |
| 692 | // the continuation point is column 4 of the partial row, not column 14 | |
| 693 | host.writeOutput(" more\n"); | |
| 694 | host.expectFrame(0, { | |
| 695 | merged: testing.MockScreen.sync([ | |
| 696 | ansi.cursorUp(3) + ansi.cursorRight(4), | |
| 697 | " more\n", | |
| 698 | "w1\n", | |
| 699 | "w2\n", | |
| 700 | ]), | |
| 701 | }); | |
| 702 | host.cancel(); | |
| 703 | }); | |
| 704 | ||
| 705 | test("partial line with ansi codes measures visible width", () => { | |
| 706 | const host = new testing.MockScreen(); | |
| 707 | using _ = host.startWidget({ format: () => "w" }); | |
| 708 | host.expectFrame(0, { | |
| 709 | stderr: testing.MockScreen.sync(["w\n"]), | |
| 710 | }); | |
| 711 | host.writeOutput(ansi.fgBlue + "blue" + ansi.fgReset); | |
| 712 | host.expectFrame(0, { | |
| 713 | merged: testing.MockScreen.sync([ | |
| 714 | ansi.cursorUp(1), | |
| 715 | ansi.clearFullLine, | |
| 716 | ansi.fgBlue + "blue" + ansi.fgReset, | |
| 717 | "\n", | |
| 718 | "w\n", | |
| 719 | ]), | |
| 720 | }); | |
| 721 | // "blue" is 4 cells wide; the escape sequences take none | |
| 722 | host.writeOutput(" sky\n"); | |
| 723 | host.expectFrame(0, { | |
| 724 | merged: testing.MockScreen.sync([ | |
| 725 | ansi.cursorUp(2) + ansi.cursorRight(4), | |
| 726 | " sky\n", | |
| 727 | "w\n", | |
| 728 | ]), | |
| 729 | }); | |
| 730 | host.cancel(); | |
| 731 | }); | |
| 732 | ||
| 733 | test("off-screen output does not move widgets", () => { | |
| 734 | // models stdout redirected to a file while stderr stays the terminal: | |
| 735 | // log output flushes to stdout, but the widget block is untouched by it | |
| 736 | const host = new testing.MockScreen({ outputSharesScreen: false }); | |
| 737 | using _ = host.startWidget({ format: () => "w1\nw2" }); | |
| 738 | host.expectFrame(0, { | |
| 739 | stderr: testing.MockScreen.sync(["w1\nw2\n"]), | |
| 740 | }); | |
| 741 | host.writeOutput("a long log line that would have wrapped on screen\n"); | |
| 742 | host.expectFrame(0, { | |
| 743 | stdout: "a long log line that would have wrapped on screen\n", | |
| 744 | stderr: testing.MockScreen.sync([ | |
| 745 | ansi.cursorUp(2), | |
| 746 | "w1\nw2\n", | |
| 747 | ]), | |
| 748 | }); | |
| 749 | // partial lines in the redirected stream do not shift the widgets either | |
| 750 | host.writeOutput("partial"); | |
| 751 | host.expectFrame(0, { | |
| 752 | stdout: "partial", | |
| 753 | stderr: testing.MockScreen.sync([ | |
| 754 | ansi.cursorUp(2), | |
| 755 | "w1\nw2\n", | |
| 756 | ]), | |
| 757 | }); | |
| 758 | host.cancel(); | |
| 759 | }); | |
| 760 | ||
| 761 | test("widgets start below a partial line", () => { | |
| 762 | const host = new testing.MockScreen(); | |
| 763 | let text = ""; | |
| 764 | const w = UNWRAP(host.startWidget({ format: () => text })); | |
| 765 | host.expectFrame(0, { merged: "" }); | |
| 766 | host.writeOutput("part"); | |
| 767 | host.expectFrame(0, { stdout: "part", merged: "part" }); | |
| 768 | text = "W"; | |
| 769 | w.redraw(); | |
| 770 | host.expectFrame(0, { | |
| 771 | stderr: testing.MockScreen.sync(["\n", "W\n"]), | |
| 772 | }); | |
| 773 | // the partial line can still be continued afterwards | |
| 774 | host.writeOutput(" two\n"); | |
| 775 | host.expectFrame(0, { | |
| 776 | merged: testing.MockScreen.sync([ | |
| 777 | ansi.cursorUp(2) + ansi.cursorRight(4), | |
| 778 | " two\n", | |
| 779 | "W\n", | |
| 780 | ]), | |
| 781 | }); | |
| 782 | host.cancel(); | |
| 783 | }); | |
| 784 | ||
| 785 | test("resize invalidates the widget area", () => { | |
| 786 | const host = new testing.MockScreen(); | |
| 787 | using _ = host.startWidget({ format: () => "w1\nw2" }); | |
| 788 | host.expectFrame(0, { | |
| 789 | stderr: testing.MockScreen.sync(["w1\nw2\n"]), | |
| 790 | }); | |
| 791 | host.resize(40); | |
| 792 | host.expectFrame(0, { | |
| 793 | merged: ansi.cursorUp(2) + "\r" + ansi.clearToEndOfScreen | |
| 794 | + testing.MockScreen.sync(["w1\nw2\n"]), | |
| 795 | }); | |
| 796 | host.cancel(); | |
| 797 | }); | |
| 798 | ||
| 799 | test("widget redraw during another widget's render", () => { | |
| 800 | const host = new testing.MockScreen(); | |
| 801 | let kicked = false; | |
| 802 | const b = UNWRAP(host.startWidget({ format: () => "b" })); | |
| 803 | using _ = host.startWidget({ | |
| 804 | format: () => { | |
| 805 | if (!kicked) { | |
| 806 | kicked = true; | |
| 807 | b.redraw(); | |
| 808 | } | |
| 809 | return "a"; | |
| 810 | }, | |
| 811 | }); | |
| 812 | host.expectFrame(0, { | |
| 813 | stderr: testing.MockScreen.sync(["b\na\n"]), | |
| 814 | }); | |
| 815 | // the request from inside the render pass produces a follow-up frame | |
| 816 | host.expectFrame(0, { | |
| 817 | stderr: testing.MockScreen.sync([ | |
| 818 | ansi.cursorUp(2), | |
| 819 | "b\na\n", | |
| 820 | ]), | |
| 821 | }); | |
| 822 | host.expectNone(); | |
| 823 | host.cancel(); | |
| 824 | }); | |
| 825 | ||
| 826 | test("widget stopped during another widget's render", () => { | |
| 827 | const host = new testing.MockScreen(); | |
| 828 | let b: log.WidgetInstance | null = null; | |
| 829 | using _ = host.startWidget({ | |
| 830 | format: () => { | |
| 831 | b?.stop(); | |
| 832 | b = null; | |
| 833 | return "a"; | |
| 834 | }, | |
| 835 | }); | |
| 836 | b = host.startWidget({ format: () => "b" }); | |
| 837 | host.expectFrame(0, { | |
| 838 | stderr: testing.MockScreen.sync(["a\n"]), | |
| 839 | }); | |
| 840 | // the stop request also schedules a follow-up frame; it draws no change | |
| 841 | host.expectFrame(0, { | |
| 842 | stderr: testing.MockScreen.sync([ | |
| 843 | ansi.cursorUp(1), | |
| 844 | "a\n", | |
| 845 | ]), | |
| 846 | }); | |
| 847 | host.expectNone(); | |
| 848 | host.cancel(); | |
| 849 | }); | |
| 850 | ||
| 851 | test("fps setter schedules frames", () => { | |
| 852 | const host = new testing.MockScreen(); | |
| 853 | const w = UNWRAP(host.startWidget({ format: ({ now }) => `t=${now}` })); | |
| 854 | host.expectFrame(0, { | |
| 855 | stderr: testing.MockScreen.sync(["t=0\n"]), | |
| 856 | }); | |
| 857 | host.expectNone(); | |
| 858 | w.fps = 1; | |
| 859 | host.expectFrame(0, { | |
| 860 | stderr: testing.MockScreen.sync([ | |
| 861 | ansi.cursorUp(1), | |
| 862 | "t=0\n", | |
| 863 | ]), | |
| 864 | }); | |
| 865 | host.expectWithoutConsume(1000); | |
| 866 | w.fps = null; | |
| 867 | host.expectFrame(0, { | |
| 868 | stderr: testing.MockScreen.sync([ | |
| 869 | ansi.cursorUp(1), | |
| 870 | "t=0\n", | |
| 871 | ]), | |
| 872 | }); | |
| 873 | host.expectNone(); | |
| 874 | w.stop(); | |
| 875 | host.expectFrame(0, { | |
| 876 | stderr: testing.MockScreen.sync([ | |
| 877 | ansi.cursorUp(1), | |
| 878 | ansi.clearFullLine, | |
| 879 | ]), | |
| 880 | }); | |
| 881 | host.cancel(); | |
| 882 | }); | |
| 883 | ||
| 884 | test("node host patches and restores std streams", async () => { | |
| 885 | const proc = UNWRAP(node.process); | |
| 886 | const origOut = proc.stdout.write; | |
| 887 | const origErr = proc.stderr.write; | |
| 888 | let stdoutData = ""; | |
| 889 | let stderrData = ""; | |
| 890 | proc.stdout.write = (chunk) => (stdoutData += chunk, true); | |
| 891 | proc.stderr.write = (chunk) => (stderrData += chunk, true); | |
| 892 | const myOut = proc.stdout.write; | |
| 893 | const myErr = proc.stderr.write; | |
| 894 | try { | |
| 895 | const host = log.defaultNodeProcessWidgetHost(proc, true); | |
| 896 | for (let i = 0; i < 3; i += 1) { | |
| 897 | host.writeOutput(`line ${i}\n`); | |
| 898 | await new Promise((resolve) => setTimeout(resolve, 2)); | |
| 899 | } | |
| 900 | host.cancel(); | |
| 901 | // regression: the unpatch used to clobber stdout with stderr's methods | |
| 902 | // and leave a permanently growing wrapper chain on stderr | |
| 903 | ASSERT(proc.stdout.write === myOut, "stdout.write was not restored"); | |
| 904 | ASSERT(proc.stderr.write === myErr, "stderr.write was not restored"); | |
| 905 | // log output lands on real stdout, so it stays redirectable | |
| 906 | ASSERT(stdoutData.includes("line 0\n")); | |
| 907 | ASSERT(stdoutData.includes("line 2\n")); | |
| 908 | ASSERT(stderrData === ""); | |
| 909 | } finally { | |
| 910 | proc.stdout.write = origOut; | |
| 911 | proc.stderr.write = origErr; | |
| 912 | } | |
| 913 | }); | |
| 914 | ||
| 527 | 915 | test("writing during a render function is OK", () => { |
| 528 | 916 | const host = new testing.MockScreen(); |
| 529 | 917 | |
| ... | ... | @@ -564,6 +952,8 @@ describe("log widgets", () => { |
| 564 | 952 | }); |
| 565 | 953 | |
| 566 | 954 | import { describe, test } from "vitest"; |
| 955 | import { ASSERT, UNWRAP } from "./assert.ts"; | |
| 567 | 956 | import * as log from "./log.ts"; |
| 957 | import * as node from "./node.ts"; | |
| 568 | 958 | import * as ansi from "./string/ansi.ts"; |
| 569 | 959 | import * as testing from "./testing.ts"; |
lib/log.ts+273-96| ... | ... | @@ -374,6 +374,15 @@ export interface TerminalWidgetHostOptions { |
| 374 | 374 | delay: typeof async.delay; |
| 375 | 375 | /** is there color support? */ |
| 376 | 376 | color: boolean; |
| 377 | /** | |
| 378 | * whether text passed to `writeOutput` lands on the same screen as the | |
| 379 | * interactive output. pass `false` when output is redirected to a file or | |
| 380 | * pipe while the interactive stream remains a terminal; the widget cursor | |
| 381 | * math then ignores log output entirely, since written rows do not | |
| 382 | * displace the widget block. | |
| 383 | * @default true | |
| 384 | */ | |
| 385 | outputSharesScreen?: boolean; | |
| 377 | 386 | } |
| 378 | 387 | |
| 379 | 388 | /** |
| ... | ... | @@ -385,9 +394,17 @@ export interface TerminalLock { |
| 385 | 394 | writeInteractive(text: string): void; |
| 386 | 395 | /** recieves log content from `write` (pre-buffered; should flush immediately) */ |
| 387 | 396 | writeOutput(text: string): void; |
| 388 | /** called often. TODO: convert this into a subscription */ | |
| 389 | getSize(): { columns: number; rows: number }; | |
| 390 | // size(cb: (value: { width: number, height: number }) => void): () => void; | |
| 397 | /** | |
| 398 | * subscribe to the terminal size. the callback must fire synchronously | |
| 399 | * with the current size before this function returns, and again whenever | |
| 400 | * the size changes (SIGWINCH). returns an unsubscribe function. on a size | |
| 401 | * change, the widget host erases its stale drawing and repaints, since a | |
| 402 | * resize rewraps previously drawn rows and invalidates all relative | |
| 403 | * cursor math. | |
| 404 | */ | |
| 405 | observeSize( | |
| 406 | callback: (size: { columns: number; rows: number }) => void, | |
| 407 | ): () => void; | |
| 391 | 408 | /** temporarily free the lock */ |
| 392 | 409 | temporaryUnlock?(): () => void; |
| 393 | 410 | /** completely free the lock */ |
| ... | ... | @@ -424,7 +441,6 @@ export interface WidgetHost { |
| 424 | 441 | /** @internal state */ |
| 425 | 442 | interface WidgetState { |
| 426 | 443 | frameTime: number; |
| 427 | next: number; | |
| 428 | 444 | } |
| 429 | 445 | |
| 430 | 446 | /** |
| ... | ... | @@ -435,15 +451,20 @@ export function createTerminalWidgetHost( |
| 435 | 451 | env: TerminalWidgetHostOptions, |
| 436 | 452 | ): WidgetHost { |
| 437 | 453 | const { lockTerminal, now, delay, writeOutputTemporaryLock, color } = env; |
| 454 | const outputOnScreen = env.outputSharesScreen ?? true; | |
| 438 | 455 | |
| 439 | 456 | let timer: async.Cancelable<void> | null = null; |
| 440 | 457 | |
| 441 | 458 | let rendering = false; |
| 459 | let pendingRedraw = false; | |
| 442 | 460 | let locks = 0; |
| 443 | 461 | let redrawTime = 0; |
| 444 | 462 | let lastFlush = 0; |
| 445 | 463 | let buffer = ""; |
| 446 | let partialLineIndex = 0; | |
| 464 | // visible width of the trailing partial log line (text since the last "\n" | |
| 465 | // written to output). the cursor column is derived as `partialWidth % | |
| 466 | // columns` at draw time, so the value survives resizes and wrapped lines. | |
| 467 | let partialWidth = 0; | |
| 447 | 468 | let needsToSaveCursor = false; |
| 448 | 469 | let needsToRestoreCursor = false; |
| 449 | 470 | const widgets: WidgetOptions[] = []; |
| ... | ... | @@ -451,8 +472,53 @@ export function createTerminalWidgetHost( |
| 451 | 472 | let lines: string[] = []; |
| 452 | 473 | let hasSyncStart = false; |
| 453 | 474 | let terminal: TerminalLock | null = null; |
| 475 | let size: { columns: number; rows: number } | null = null; | |
| 476 | let unwatchSize: (() => void) | null = null; | |
| 454 | 477 | let tempUnlock: (() => void) | null = null; |
| 455 | 478 | |
| 479 | // the size subscription lives exactly as long as the terminal lock, so | |
| 480 | // acquisition and closing are funneled through these two functions. | |
| 481 | function acquireTerminal(): TerminalLock { | |
| 482 | if (!terminal) { | |
| 483 | let initial = true; | |
| 484 | size = null; | |
| 485 | terminal = lockTerminal(); | |
| 486 | unwatchSize = terminal.observeSize((next) => { | |
| 487 | size = next; | |
| 488 | if (!initial) handleResize(); | |
| 489 | }); | |
| 490 | ASSERT( | |
| 491 | size, | |
| 492 | "TerminalLock.observeSize must call back synchronously with the current size", | |
| 493 | ); | |
| 494 | initial = false; | |
| 495 | } | |
| 496 | return terminal; | |
| 497 | } | |
| 498 | function closeTerminal() { | |
| 499 | unwatchSize?.(); | |
| 500 | unwatchSize = null; | |
| 501 | terminal?.close(); | |
| 502 | terminal = null; | |
| 503 | } | |
| 504 | ||
| 505 | function handleResize() { | |
| 506 | // previously drawn rows have rewrapped to the new width, so the stored | |
| 507 | // line count no longer matches the screen. erasing from the widget top | |
| 508 | // downward is best-effort: on shrink, rows that wrapped above the cursor | |
| 509 | // cannot be recovered. | |
| 510 | if (lines.length > 0 && terminal) { | |
| 511 | terminal.writeInteractive( | |
| 512 | ansi.cursorUp(lines.length) + "\r" + ansi.clearToEndOfScreen, | |
| 513 | ); | |
| 514 | lines = []; | |
| 515 | } | |
| 516 | // the save register holds pre-resize coordinates; restoring it would | |
| 517 | // jump somewhere unrelated. | |
| 518 | needsToRestoreCursor = false; | |
| 519 | requestRedraw(); | |
| 520 | } | |
| 521 | ||
| 456 | 522 | function redrawCallback() { |
| 457 | 523 | timer = null; |
| 458 | 524 | ASSERT(!rendering); |
| ... | ... | @@ -464,6 +530,16 @@ export function createTerminalWidgetHost( |
| 464 | 530 | redrawCallbackInner(); |
| 465 | 531 | } finally { |
| 466 | 532 | rendering = false; |
| 533 | if (pendingRedraw) { | |
| 534 | pendingRedraw = false; | |
| 535 | redrawSoon(0); | |
| 536 | } | |
| 537 | // cancel() during this render pass (an exit handler unwinding through | |
| 538 | // a crashed format callback) skips its teardown; finish it here. | |
| 539 | if (widgets.length === 0 && terminal && !timer && !buffer) { | |
| 540 | lines = []; | |
| 541 | closeTerminal(); | |
| 542 | } | |
| 467 | 543 | } |
| 468 | 544 | } |
| 469 | 545 | |
| ... | ... | @@ -477,33 +553,27 @@ export function createTerminalWidgetHost( |
| 477 | 553 | if (!buffer) return; |
| 478 | 554 | needsToRestoreCursor = false; |
| 479 | 555 | needsToSaveCursor = false; |
| 480 | if (writeOutputTemporaryLock) { | |
| 556 | if (!terminal && writeOutputTemporaryLock) { | |
| 481 | 557 | writeOutputTemporaryLock(buffer); |
| 482 | if (hasSyncStart) { | |
| 483 | terminal ??= lockTerminal(); | |
| 484 | terminal.writeInteractive(ansi.syncEnd); | |
| 485 | } | |
| 486 | 558 | } else { |
| 487 | terminal ??= lockTerminal(); | |
| 488 | buffer && terminal.writeOutput(buffer); | |
| 489 | if (hasSyncStart) { | |
| 490 | terminal ??= lockTerminal(); | |
| 491 | terminal.writeInteractive(ansi.syncEnd); | |
| 492 | } | |
| 493 | terminal.close(); | |
| 494 | terminal = null; | |
| 559 | acquireTerminal().writeOutput(buffer); | |
| 495 | 560 | } |
| 496 | partialLineIndex = partialLineLength(buffer); | |
| 561 | if (hasSyncStart) { | |
| 562 | acquireTerminal().writeInteractive(ansi.syncEnd); | |
| 563 | hasSyncStart = false; | |
| 564 | } | |
| 565 | closeTerminal(); | |
| 566 | trackPartialWidth(buffer); | |
| 497 | 567 | buffer = ""; |
| 498 | 568 | return; |
| 499 | 569 | } |
| 500 | 570 | |
| 501 | terminal ??= lockTerminal(); | |
| 502 | ||
| 503 | const { columns, rows } = terminal.getSize(); | |
| 571 | const term = acquireTerminal(); | |
| 572 | const { columns, rows } = UNWRAP(size); | |
| 504 | 573 | let newWidgetLines: string[] = []; |
| 505 | 574 | let next = Infinity; |
| 506 | for (let w = 0, { length } = widgets; w < length; w += 1) { | |
| 575 | // `widgets.length` is read live: a format callback may stop another widget | |
| 576 | for (let w = 0; w < widgets.length; w += 1) { | |
| 507 | 577 | const widget = UNWRAP(widgets[w]); |
| 508 | 578 | let out: string | { text: string } | null; |
| 509 | 579 | try { |
| ... | ... | @@ -515,50 +585,68 @@ export function createTerminalWidgetHost( |
| 515 | 585 | } catch (e) { |
| 516 | 586 | out = e instanceof Error ? stack.format(e, color) : errors.message(e); |
| 517 | 587 | } |
| 518 | if (!out) { | |
| 588 | if (out == null) { | |
| 519 | 589 | widgets.splice(w, 1); |
| 520 | 590 | UNWRAP(internals.splice(w, 1)[0]); |
| 521 | 591 | w -= 1; |
| 522 | length -= 1; | |
| 523 | 592 | continue; |
| 524 | 593 | } |
| 594 | next = Math.min(next, UNWRAP(internals[w]).frameTime); | |
| 525 | 595 | const outText = typeof out === "string" ? out : out.text; |
| 596 | // an empty string is a live widget that currently displays nothing; | |
| 597 | // only `null` detaches. | |
| 598 | if (outText === "") continue; | |
| 526 | 599 | const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); |
| 527 | if (rowsLeft === 1) break; | |
| 528 | const lines = outText.split("\n").slice(0, rowsLeft); | |
| 600 | if (rowsLeft === 1) continue; | |
| 529 | 601 | newWidgetLines.push( |
| 530 | ...lines.map((line) => ansi.trimForTerminal(line, columns - 1)), | |
| 602 | ...outText.split("\n").slice(0, rowsLeft) | |
| 603 | .map((line) => ansi.trimForTerminal(line, columns - 1)), | |
| 531 | 604 | ); |
| 532 | ||
| 533 | next = Math.min(next, UNWRAP(internals[w]).frameTime); | |
| 534 | 605 | } |
| 535 | 606 | newWidgetLines = newWidgetLines.slice(0, rows - 1); |
| 536 | 607 | if (next < Infinity) redrawSoon(next); |
| 537 | 608 | |
| 538 | terminal ??= lockTerminal(); | |
| 609 | const pCol = partialWidth % columns; | |
| 539 | 610 | |
| 540 | if (!newWidgetLines[0]) { | |
| 541 | ASSERT(!needsToSaveCursor); | |
| 611 | if (newWidgetLines.length === 0) { | |
| 542 | 612 | if (lines.length > 0) { |
| 543 | terminal.writeInteractive( | |
| 613 | term.writeInteractive( | |
| 544 | 614 | (hasSyncStart ? "" : ansi.syncStart) |
| 545 | 615 | // clear the widget space |
| 546 | 616 | + (ansi.cursorUp(1) + ansi.clearFullLine) |
| 547 | 617 | .repeat(lines.length) |
| 548 | + (partialLineIndex | |
| 549 | ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex) | |
| 618 | + (pCol | |
| 619 | ? ansi.cursorUp(1) + ansi.cursorRight(pCol) | |
| 550 | 620 | : "") |
| 551 | 621 | + (needsToRestoreCursor ? ansi.cursorRestore : ""), |
| 552 | 622 | ); |
| 553 | 623 | hasSyncStart = true; |
| 624 | needsToRestoreCursor = false; | |
| 554 | 625 | lines = []; |
| 555 | 626 | } |
| 556 | if (buffer) terminal.writeOutput(buffer); | |
| 557 | buffer = ""; | |
| 558 | if (hasSyncStart) terminal.writeInteractive(ansi.syncEnd); | |
| 627 | if (buffer) { | |
| 628 | term.writeOutput(buffer); | |
| 629 | trackPartialWidth(buffer); | |
| 630 | buffer = ""; | |
| 631 | } | |
| 632 | if (hasSyncStart) { | |
| 633 | term.writeInteractive(ansi.syncEnd); | |
| 634 | hasSyncStart = false; | |
| 635 | } | |
| 636 | // the last widget may have detached this frame; release the terminal | |
| 637 | // (and its patches) instead of holding the lock until the next flush | |
| 638 | if (widgets.length === 0) closeTerminal(); | |
| 559 | 639 | return; |
| 560 | 640 | } |
| 561 | 641 | |
| 642 | if (buffer && !outputOnScreen) { | |
| 643 | // off-screen output (e.g. stdout redirected to a file) does not | |
| 644 | // interact with the widget block; flush it plainly and fall through | |
| 645 | // to a pure widget redraw | |
| 646 | term.writeOutput(buffer); | |
| 647 | buffer = ""; | |
| 648 | } | |
| 649 | ||
| 562 | 650 | if (buffer) { |
| 563 | 651 | // when writing a buffer alongside widgets, the screen may look like this |
| 564 | 652 | // > [existing log] |
| ... | ... | @@ -568,21 +656,24 @@ export function createTerminalWidgetHost( |
| 568 | 656 | // > [widget line 3] |
| 569 | 657 | // > [cursor is start of this line] |
| 570 | 658 | // |
| 571 | // first, clear out the space where new lines are going to intersect | |
| 572 | const createsPartialLine = partialLineLength(buffer); | |
| 573 | // if more lines are buffered than there are widgets, only some are needed | |
| 659 | // first, clear out the space where new lines are going to intersect. | |
| 660 | // `span.rows` measures the cursor descent in physical rows, so log | |
| 661 | // lines wider than the terminal are accounted for correctly. | |
| 662 | const span = measureTerminalSpan(buffer, pCol, columns); | |
| 663 | // if more rows are buffered than there are widgets, only some are | |
| 664 | // needed. when a partial line exists, the buffer starts on its row | |
| 665 | // (one above the widget block), hence the -1. | |
| 574 | 666 | const clearLinesTop = Math.min( |
| 575 | 667 | lines.length, |
| 576 | string.countNewlines(buffer) | |
| 577 | + (createsPartialLine ? 1 : 0) | |
| 578 | + (partialLineIndex ? -1 : 0), // "consumes partial line" | |
| 668 | span.rows | |
| 669 | + (span.endWidth % columns > 0 ? 1 : 0) | |
| 670 | + (pCol ? -1 : 0), | |
| 579 | 671 | ); |
| 580 | 672 | const oldLines = lines.slice(clearLinesTop); |
| 581 | terminal.writeInteractive( | |
| 673 | term.writeInteractive( | |
| 582 | 674 | (needsToSaveCursor ? ansi.cursorSave + "\n" : "") |
| 583 | 675 | + (hasSyncStart ? "" : ansi.syncStart) |
| 584 | + (partialLineIndex && !lines[0] ? "\n" : "") | |
| 585 | + ((clearLinesTop > 0 || (partialLineIndex && lines[0])) | |
| 676 | + ((clearLinesTop > 0 || (pCol && lines.length > 0)) | |
| 586 | 677 | // clear the lines for buffer |
| 587 | 678 | ? (clearLinesTop > 0 |
| 588 | 679 | ? ansi.cursorUp(lines.length - clearLinesTop + 1) |
| ... | ... | @@ -590,22 +681,23 @@ export function createTerminalWidgetHost( |
| 590 | 681 | + (ansi.cursorUp(1) + ansi.clearFullLine) |
| 591 | 682 | .repeat(clearLinesTop - 1) |
| 592 | 683 | : "") |
| 593 | + (partialLineIndex && lines[0] | |
| 684 | + (pCol && lines.length > 0 | |
| 594 | 685 | ? ansi.cursorUp( |
| 595 | 686 | clearLinesTop > 0 ? 1 : lines.length + 1, |
| 596 | 687 | ) |
| 597 | + ansi.cursorRight(partialLineIndex) | |
| 688 | + ansi.cursorRight(pCol) | |
| 598 | 689 | : "") |
| 599 | 690 | : ""), |
| 600 | 691 | ); |
| 601 | partialLineIndex = createsPartialLine; | |
| 602 | 692 | // then write output lines on standard out |
| 603 | terminal.writeOutput(buffer); | |
| 604 | terminal.writeInteractive( | |
| 605 | // if a partial line is created, then the widgets | |
| 606 | // have to go on the next line, to avoid breaking stdout, | |
| 607 | // the newline gets emitted on the interactive out. | |
| 608 | (createsPartialLine ? "\n" : "") | |
| 693 | term.writeOutput(buffer); | |
| 694 | term.writeInteractive( | |
| 695 | // if the buffer leaves a partial line, the widgets have to go on the | |
| 696 | // next line. to avoid breaking stdout, the newline gets emitted on | |
| 697 | // the interactive out. (a line ending exactly on the terminal edge | |
| 698 | // leaves the cursor wrap-deferred; this newline lands on the next | |
| 699 | // row, which is also where `span.rows` placed the continuation.) | |
| 700 | (span.endWidth > 0 ? "\n" : "") | |
| 609 | 701 | // the widget text |
| 610 | 702 | + newWidgetLines.map((newLine, i) => |
| 611 | 703 | (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset) |
| ... | ... | @@ -620,14 +712,20 @@ export function createTerminalWidgetHost( |
| 620 | 712 | + "\n" |
| 621 | 713 | ).join("") + ansi.syncEnd, |
| 622 | 714 | ); |
| 715 | partialWidth = span.endWidth; | |
| 623 | 716 | } else { |
| 624 | 717 | const clearLinesBottom = Math.min( |
| 625 | 718 | lines.length, |
| 626 | Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), | |
| 719 | Math.max(0, lines.length - newWidgetLines.length), | |
| 627 | 720 | ); |
| 628 | terminal.writeInteractive( | |
| 721 | term.writeInteractive( | |
| 629 | 722 | (needsToSaveCursor ? ansi.cursorSave + "\n" : "") |
| 630 | 723 | + (hasSyncStart ? "" : ansi.syncStart) |
| 724 | // the first draw can land just after a partial log line; widgets | |
| 725 | // must move below it. (`partialWidth` is zero whenever | |
| 726 | // `needsToSaveCursor` is set, since releasing a draw lock resets | |
| 727 | // it, so this never combines with the cursorSave newline.) | |
| 728 | + (lines.length === 0 && partialWidth > 0 ? "\n" : "") | |
| 631 | 729 | // clear the bottom lines |
| 632 | 730 | + (clearLinesBottom |
| 633 | 731 | ? (ansi.cursorUp(1) + ansi.clearToEndOfLine) |
| ... | ... | @@ -658,6 +756,17 @@ export function createTerminalWidgetHost( |
| 658 | 756 | buffer = ""; |
| 659 | 757 | } |
| 660 | 758 | |
| 759 | /** | |
| 760 | * update `partialWidth` after writing `text` to the output. rows written | |
| 761 | * off-screen never displace the widget block, so they are not tracked. | |
| 762 | */ | |
| 763 | function trackPartialWidth(text: string) { | |
| 764 | if (!outputOnScreen) return; | |
| 765 | const i = text.lastIndexOf("\n"); | |
| 766 | partialWidth = ansi.widthInTerminal(text.slice(i + 1)) | |
| 767 | + (i === -1 ? partialWidth : 0); | |
| 768 | } | |
| 769 | ||
| 661 | 770 | function redrawSoon(ms: number) { |
| 662 | 771 | if (locks > 0 || (ms === 0 && rendering)) return; |
| 663 | 772 | const newRedrawTime = now() + ms; |
| ... | ... | @@ -671,35 +780,40 @@ export function createTerminalWidgetHost( |
| 671 | 780 | timer.then(redrawCallback); |
| 672 | 781 | } |
| 673 | 782 | |
| 783 | function requestRedraw() { | |
| 784 | if (rendering) pendingRedraw = true; | |
| 785 | else redrawSoon(0); | |
| 786 | } | |
| 787 | ||
| 674 | 788 | function flushAndClear(shortTermDrawLock: boolean) { |
| 675 | 789 | timer?.cancel(); |
| 676 | 790 | timer = null; |
| 677 | 791 | if (lines.length > 0) { |
| 678 | UNWRAP(terminal).writeInteractive( | |
| 792 | const term = UNWRAP(terminal); | |
| 793 | const pCol = partialWidth % UNWRAP(size).columns; | |
| 794 | term.writeInteractive( | |
| 679 | 795 | ansi.syncStart |
| 680 | 796 | // clear the widget space |
| 681 | 797 | + (ansi.cursorUp(1) + ansi.clearFullLine) |
| 682 | 798 | .repeat(lines.length) |
| 683 | + (partialLineIndex | |
| 684 | ? ansi.cursorUp(1) + ansi.cursorRight(partialLineIndex) | |
| 799 | + (pCol | |
| 800 | ? ansi.cursorUp(1) + ansi.cursorRight(pCol) | |
| 685 | 801 | : "") |
| 686 | 802 | + (shortTermDrawLock ? "" : ansi.syncEnd) |
| 687 | 803 | + (needsToRestoreCursor ? ansi.cursorRestore : ""), |
| 688 | 804 | ); |
| 805 | needsToRestoreCursor = false; | |
| 689 | 806 | lines = []; |
| 690 | 807 | hasSyncStart = shortTermDrawLock; |
| 691 | 808 | } |
| 692 | 809 | if (buffer.length > 0) { |
| 693 | if (widgets.length === 0 && writeOutputTemporaryLock) { | |
| 810 | if (widgets.length === 0 && !terminal && writeOutputTemporaryLock) { | |
| 694 | 811 | writeOutputTemporaryLock(buffer); |
| 695 | 812 | } else { |
| 696 | terminal ??= lockTerminal(); | |
| 697 | terminal.writeOutput(buffer); | |
| 698 | if (widgets.length === 0) { | |
| 699 | terminal.close(); | |
| 700 | terminal = null; | |
| 701 | } | |
| 813 | acquireTerminal().writeOutput(buffer); | |
| 814 | if (widgets.length === 0) closeTerminal(); | |
| 702 | 815 | } |
| 816 | trackPartialWidth(buffer); | |
| 703 | 817 | buffer = ""; |
| 704 | 818 | } |
| 705 | 819 | } |
| ... | ... | @@ -720,8 +834,7 @@ export function createTerminalWidgetHost( |
| 720 | 834 | if (terminal.temporaryUnlock) { |
| 721 | 835 | tempUnlock = terminal.temporaryUnlock(); |
| 722 | 836 | } else { |
| 723 | terminal.close(); | |
| 724 | terminal = null; | |
| 837 | closeTerminal(); | |
| 725 | 838 | } |
| 726 | 839 | } |
| 727 | 840 | } |
| ... | ... | @@ -730,14 +843,19 @@ export function createTerminalWidgetHost( |
| 730 | 843 | return { |
| 731 | 844 | release(mode) { |
| 732 | 845 | if (disposed) return; |
| 846 | disposed = true; | |
| 733 | 847 | locks -= 1; |
| 734 | 848 | needsToSaveCursor ||= mode === "cursor-middle-of-line"; |
| 849 | // whatever the lock holder wrote has detached the cursor from any | |
| 850 | // previously tracked partial log line. "cursor-start-of-line" | |
| 851 | // states the cursor is on a fresh line; "cursor-middle-of-line" | |
| 852 | // engages the save/restore dance instead. | |
| 853 | partialWidth = 0; | |
| 735 | 854 | if (locks === 0) { |
| 736 | 855 | tempUnlock?.(); |
| 737 | 856 | tempUnlock = null; |
| 738 | 857 | if (buffer.length > 0 || widgets.length > 0) redrawSoon(0); |
| 739 | 858 | } |
| 740 | disposed = true; | |
| 741 | 859 | }, |
| 742 | 860 | [Symbol.dispose]() { |
| 743 | 861 | this.release("cursor-middle-of-line"); |
| ... | ... | @@ -748,12 +866,11 @@ export function createTerminalWidgetHost( |
| 748 | 866 | ASSERT(!widgets.includes(options), "Cannot start the same widget twice."); |
| 749 | 867 | let fps = options.fps ?? null; |
| 750 | 868 | const state: WidgetState = { |
| 751 | next: 0, | |
| 752 | 869 | frameTime: 1000 / (fps ?? 0), |
| 753 | 870 | }; |
| 754 | 871 | widgets.push(options); |
| 755 | 872 | internals.push(state); |
| 756 | redrawSoon(0); | |
| 873 | requestRedraw(); | |
| 757 | 874 | return { |
| 758 | 875 | options, |
| 759 | 876 | get fps() { |
| ... | ... | @@ -762,17 +879,16 @@ export function createTerminalWidgetHost( |
| 762 | 879 | set fps(value) { |
| 763 | 880 | fps = value; |
| 764 | 881 | state.frameTime = 1000 / (fps ?? 0); |
| 882 | // a pending frame may sit beyond the new cadence; draw to reanchor | |
| 883 | requestRedraw(); | |
| 765 | 884 | }, |
| 766 | redraw() { | |
| 767 | state.next = 0; | |
| 768 | redrawSoon(0); | |
| 769 | }, | |
| 885 | redraw: requestRedraw, | |
| 770 | 886 | stop() { |
| 771 | 887 | const i = widgets.indexOf(options); |
| 772 | 888 | if (i === -1) return; |
| 773 | 889 | widgets.splice(i, 1); |
| 774 | 890 | UNWRAP(internals.splice(i, 1)[0]); |
| 775 | redrawSoon(0); | |
| 891 | requestRedraw(); | |
| 776 | 892 | }, |
| 777 | 893 | [Symbol.dispose]() { |
| 778 | 894 | this.stop(); |
| ... | ... | @@ -787,6 +903,9 @@ export function createTerminalWidgetHost( |
| 787 | 903 | if (!rendering) flushAndClear(false); |
| 788 | 904 | widgets.splice(0, widgets.length); |
| 789 | 905 | internals.splice(0, internals.length); |
| 906 | timer?.cancel(); | |
| 907 | timer = null; | |
| 908 | if (!rendering) closeTerminal(); | |
| 790 | 909 | }, |
| 791 | 910 | delay, |
| 792 | 911 | now, |
| ... | ... | @@ -794,13 +913,35 @@ export function createTerminalWidgetHost( |
| 794 | 913 | }; |
| 795 | 914 | } |
| 796 | 915 | |
| 797 | function partialLineLength(text: string) { | |
| 798 | const idx = text.lastIndexOf("\n"); | |
| 799 | if (idx === -1) return text.length; | |
| 800 | return text.length - idx - 1; | |
| 916 | /** | |
| 917 | * compute the cursor descent (`rows`) and trailing line width (`endWidth`) | |
| 918 | * from writing `text` to a terminal `columns` wide, with the cursor starting | |
| 919 | * `startWidth` cells into a line. wrapping follows the DECAWM deferred-wrap | |
| 920 | * convention shared by modern terminals: a line of exactly `columns` cells | |
| 921 | * leaves the cursor pending on the same row, so a newline after it descends | |
| 922 | * only one row. a trailing line ending exactly on the boundary reports its | |
| 923 | * continuation point at column 0 of the next row (`endWidth % columns === 0` | |
| 924 | * with the descended row included in `rows`). | |
| 925 | */ | |
| 926 | function measureTerminalSpan( | |
| 927 | text: string, | |
| 928 | startWidth: number, | |
| 929 | columns: number, | |
| 930 | ): { rows: number; endWidth: number } { | |
| 931 | let rows = 0; | |
| 932 | let width = startWidth; | |
| 933 | const parts = text.split("\n"); | |
| 934 | for (let i = 0; i < parts.length - 1; i += 1) { | |
| 935 | width += ansi.widthInTerminal(UNWRAP(parts[i])); | |
| 936 | rows += Math.max(1, Math.ceil(width / columns)); | |
| 937 | width = 0; | |
| 938 | } | |
| 939 | width += ansi.widthInTerminal(UNWRAP(parts[parts.length - 1])); | |
| 940 | rows += Math.floor(width / columns); | |
| 941 | return { rows, endWidth: width }; | |
| 801 | 942 | } |
| 802 | 943 | |
| 803 | const logColors = node.process?.stderr.isTTY ?? false; | |
| 944 | const logColors = node.process?.stdout.isTTY ?? false; | |
| 804 | 945 | |
| 805 | 946 | let formatLine = /* @__PURE__ */ (() => { |
| 806 | 947 | const fwo = node.builtin("util")?.formatWithOptions; |
| ... | ... | @@ -944,7 +1085,7 @@ interface GlobalCommunication { |
| 944 | 1085 | } |
| 945 | 1086 | |
| 946 | 1087 | const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log"); |
| 947 | const version = 4; | |
| 1088 | const version = 5; | |
| 948 | 1089 | let global: GlobalCommunication = /* @__PURE__ */ (() => { |
| 949 | 1090 | const global = (globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= { |
| 950 | 1091 | readme: |
| ... | ... | @@ -1032,13 +1173,16 @@ export function defaultNodeProcessWidgetHost( |
| 1032 | 1173 | const stderrEnd = stderr.end; |
| 1033 | 1174 | function patchWriteMethod<T, R, A extends [string | Uint8Array]>( |
| 1034 | 1175 | fn: (this: T, ...args: A) => R, |
| 1176 | touchesScreen: boolean, | |
| 1035 | 1177 | ) { |
| 1036 | 1178 | return function(this: T, ...args: A) { |
| 1037 | 1179 | using lock = disposed ? null : host.getDrawLock("short"); |
| 1038 | 1180 | const ret = fn.apply(this, args); |
| 1039 | 1181 | if (lock) { |
| 1182 | // writes to a redirected stream never move the screen cursor | |
| 1040 | 1183 | lock.release( |
| 1041 | (typeof args[0] === "string" | |
| 1184 | !touchesScreen | |
| 1185 | || (typeof args[0] === "string" | |
| 1042 | 1186 | ? args[0].endsWith("\n") |
| 1043 | 1187 | : bufferEndsInNewline(args[0])) |
| 1044 | 1188 | ? "cursor-start-of-line" |
| ... | ... | @@ -1048,8 +1192,14 @@ export function defaultNodeProcessWidgetHost( |
| 1048 | 1192 | return ret; |
| 1049 | 1193 | }; |
| 1050 | 1194 | } |
| 1051 | const newStdoutWrite = stdout.write = patchWriteMethod(stdoutWrite); | |
| 1052 | const newStderrWrite = stderr.write = patchWriteMethod(stderrWrite); | |
| 1195 | const newStdoutWrite = stdout.write = patchWriteMethod( | |
| 1196 | stdoutWrite, | |
| 1197 | stdout.isTTY, | |
| 1198 | ); | |
| 1199 | const newStderrWrite = stderr.write = patchWriteMethod( | |
| 1200 | stderrWrite, | |
| 1201 | true, | |
| 1202 | ); | |
| 1053 | 1203 | function patchEndMethod<T, R, A extends unknown[]>( |
| 1054 | 1204 | fn: (this: T, ...args: A) => R, |
| 1055 | 1205 | ) { |
| ... | ... | @@ -1090,19 +1240,34 @@ export function defaultNodeProcessWidgetHost( |
| 1090 | 1240 | } |
| 1091 | 1241 | |
| 1092 | 1242 | return { |
| 1093 | writeOutput: (string) => stdoutWrite.call(stderr, string), | |
| 1243 | // log output goes to real stdout so it stays redirectable; the | |
| 1244 | // `outputSharesScreen` option tells the host whether those rows | |
| 1245 | // land on the widget screen and must be counted by the cursor math | |
| 1246 | writeOutput: (string) => stdoutWrite.call(stdout, string), | |
| 1094 | 1247 | writeInteractive: (string) => stderrWrite.call(stderr, string), |
| 1095 | getSize: () => process.stderr, | |
| 1096 | temporarilyUnlock() { | |
| 1097 | // no action needed | |
| 1248 | observeSize(callback) { | |
| 1249 | const emit = () => | |
| 1250 | callback({ | |
| 1251 | columns: stderr.columns ?? 80, | |
| 1252 | rows: stderr.rows ?? 24, | |
| 1253 | }); | |
| 1254 | emit(); | |
| 1255 | stderr.addListener("resize", emit); | |
| 1256 | return () => void stderr.removeListener("resize", emit); | |
| 1257 | }, | |
| 1258 | temporaryUnlock() { | |
| 1259 | // no action needed: the patched write methods re-enter the draw | |
| 1260 | // lock, which is re-entrant, so external writes flow correctly | |
| 1261 | // while the lock is held. | |
| 1262 | return () => {}; | |
| 1098 | 1263 | }, |
| 1099 | 1264 | close() { |
| 1100 | 1265 | disposed = true; |
| 1101 | 1266 | // leave patches in place if something else tampered with it. |
| 1102 | 1267 | if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite; |
| 1103 | if (stderr.write === newStderrWrite) stdout.write = stderrWrite; | |
| 1268 | if (stderr.write === newStderrWrite) stderr.write = stderrWrite; | |
| 1104 | 1269 | if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd; |
| 1105 | if (stderr.end === newStderrEnd) stdout.end = stderrEnd; | |
| 1270 | if (stderr.end === newStderrEnd) stderr.end = stderrEnd; | |
| 1106 | 1271 | for (const [key, old, patched] of restoreConsole) { |
| 1107 | 1272 | if (console[key] === patched) console[key] = old; |
| 1108 | 1273 | } |
| ... | ... | @@ -1112,6 +1277,7 @@ export function defaultNodeProcessWidgetHost( |
| 1112 | 1277 | now: () => performance.now(), |
| 1113 | 1278 | delay: async.delay, |
| 1114 | 1279 | color: process.stderr.isTTY, |
| 1280 | outputSharesScreen: process.stdout.isTTY, | |
| 1115 | 1281 | }); |
| 1116 | 1282 | process.addListener("beforeExit", () => host.cancel()); |
| 1117 | 1283 | process.addListener("exit", () => host.cancel()); |
| ... | ... | @@ -1156,7 +1322,16 @@ export function simpleNodeProcessWidgetHost( |
| 1156 | 1322 | lockTerminal: () => ({ |
| 1157 | 1323 | writeOutput: (string) => process.stdout.write(string), |
| 1158 | 1324 | writeInteractive: (string) => process.stderr.write(string), |
| 1159 | getSize: () => process.stderr, | |
| 1325 | observeSize(callback) { | |
| 1326 | const emit = () => | |
| 1327 | callback({ | |
| 1328 | columns: process.stderr.columns ?? 80, | |
| 1329 | rows: process.stderr.rows ?? 24, | |
| 1330 | }); | |
| 1331 | emit(); | |
| 1332 | process.stderr.addListener("resize", emit); | |
| 1333 | return () => void process.stderr.removeListener("resize", emit); | |
| 1334 | }, | |
| 1160 | 1335 | close() { |
| 1161 | 1336 | // no action needed |
| 1162 | 1337 | }, |
| ... | ... | @@ -1164,6 +1339,7 @@ export function simpleNodeProcessWidgetHost( |
| 1164 | 1339 | now: () => performance.now(), |
| 1165 | 1340 | delay: async.delay, |
| 1166 | 1341 | color: process.stderr.isTTY, |
| 1342 | outputSharesScreen: process.stdout.isTTY, | |
| 1167 | 1343 | }) |
| 1168 | 1344 | : { |
| 1169 | 1345 | writeOutput: (string) => process.stdout.write(string), |
| ... | ... | @@ -1201,6 +1377,8 @@ const globalLog = /* @__PURE__ */ (() => |
| 1201 | 1377 | } else if (node.process) { |
| 1202 | 1378 | globalWidgetHost()[ |
| 1203 | 1379 | (m.level ?? "info") === "info" ? "writeOutput" : "writeError" |
| 1380 | // colors keyed off stdout, the destination of host log output; | |
| 1381 | // `logColors` (used for inspect formatting) matches. | |
| 1204 | 1382 | ](formatAnsiMessage(m, node.process.stdout.isTTY)); |
| 1205 | 1383 | } else { |
| 1206 | 1384 | let { level = "info", [originalLogArgs]: args = [m.text], scope } = m; |
| ... | ... | @@ -1233,6 +1411,5 @@ import * as async from "./async.ts"; |
| 1233 | 1411 | import * as errors from "./error.ts"; |
| 1234 | 1412 | import * as stack from "./log/stack.ts"; |
| 1235 | 1413 | import * as node from "./node.ts"; |
| 1236 | import * as string from "./string.ts"; | |
| 1237 | 1414 | import * as ansi from "./string/ansi.ts"; |
| 1238 | 1415 | import * as ts from "./ts.ts"; |
lib/node.ts+5-2| ... | ... | @@ -30,9 +30,12 @@ interface Process { |
| 30 | 30 | |
| 31 | 31 | interface Tty { |
| 32 | 32 | isTTY: boolean; |
| 33 | columns: number; | |
| 34 | rows: number; | |
| 33 | /** only present when the stream is a tty */ | |
| 34 | columns: number | undefined; | |
| 35 | /** only present when the stream is a tty */ | |
| 36 | rows: number | undefined; | |
| 35 | 37 | addListener(event: string, callback: () => void): this; |
| 38 | removeListener(event: string, callback: () => void): this; | |
| 36 | 39 | end(text?: string | Uint8Array): this; |
| 37 | 40 | write(text: string | Uint8Array): boolean; |
| 38 | 41 | } |
lib/progress.test.ts+155-6| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | // a trivial example of how to use progress |
| 2 | test.skip("trivial end-to-end example", () => { | |
| 2 | test("trivial end-to-end example", () => { | |
| 3 | 3 | const { fgBlue: FB, fgReset: FR, reset: R } = ansi; |
| 4 | 4 | const screen = new testing.MockScreen(); // create a mock terminal |
| 5 | 5 | const root = new progress.Root(screen); // sync with mock timers |
| ... | ... | @@ -13,18 +13,20 @@ test.skip("trivial end-to-end example", () => { |
| 13 | 13 | assert.equal(screen.timers.entries.length, 1); |
| 14 | 14 | |
| 15 | 15 | screen.expectFrame(1, { merged: "" }); // debounce |
| 16 | // TODO: why the full resets? | |
| 16 | // widget lines containing escapes get a trailing ansi.reset from the | |
| 17 | // renderer, so a malformed format function cannot bleed styles | |
| 17 | 18 | screen.expectFrame(0, { |
| 18 | 19 | merged: testing.MockScreen.sync([ |
| 19 | `\r${FB}⠋${FR} hello${R}\n`, | |
| 20 | `${FB}⠋${FR} hello${R}\n`, | |
| 20 | 21 | `${FB}⠋${FR} cats${R}\n`, |
| 21 | 22 | "└─ meow\n", |
| 22 | 23 | ]), |
| 23 | 24 | }); |
| 24 | 25 | |
| 26 | // spinners animate from the clock, so frames tick at the spinner rate | |
| 25 | 27 | screen.expectFrame(80, { |
| 26 | 28 | merged: testing.MockScreen.sync([ |
| 27 | `\r` + ansi.cursorUp(3), | |
| 29 | ansi.cursorUp(3), | |
| 28 | 30 | `${FB}⠙${FR} hello${R}\n`, |
| 29 | 31 | `${FB}⠙${FR} cats${R}\n`, |
| 30 | 32 | "└─ meow\n", |
| ... | ... | @@ -34,13 +36,66 @@ test.skip("trivial end-to-end example", () => { |
| 34 | 36 | a.end(); |
| 35 | 37 | b.end(); |
| 36 | 38 | |
| 37 | screen.expectFrame(80, { | |
| 39 | // ending fires the change debounce, which stops the widget and clears it | |
| 40 | screen.expectFrame(1, { merged: "" }); | |
| 41 | screen.expectFrame(0, { | |
| 38 | 42 | merged: testing.MockScreen.sync([ |
| 39 | `\r` + ansi.cursorUp(1) + ansi.clearFullLine, | |
| 40 | 43 | ansi.cursorUp(1) + ansi.clearFullLine, |
| 41 | 44 | ansi.cursorUp(1) + ansi.clearFullLine, |
| 45 | ansi.cursorUp(1) + ansi.clearFullLine, | |
| 46 | ]), | |
| 47 | }); | |
| 48 | screen.expectNone(); | |
| 49 | }); | |
| 50 | ||
| 51 | test("bars without estimates do not animate", () => { | |
| 52 | const screen = new testing.MockScreen(); | |
| 53 | const root = new progress.Root(screen); | |
| 54 | progress.attachToScreen(root, screen); | |
| 55 | ||
| 56 | const a = root.start("download", { total: 4, value: 1 }); | |
| 57 | screen.expectFrame(1, { merged: "" }); // debounce | |
| 58 | screen.expectFrame(0, {}); // initial draw | |
| 59 | // a bar is static between value changes, so no frame timer is pending | |
| 60 | screen.expectNone(); | |
| 61 | ||
| 62 | a.inc(); | |
| 63 | screen.expectFrame(1, { merged: "" }); | |
| 64 | screen.expectFrame(0, {}); | |
| 65 | screen.expectNone(); | |
| 66 | ||
| 67 | a.end(); | |
| 68 | screen.expectFrame(1, { merged: "" }); | |
| 69 | screen.expectFrame(0, {}); | |
| 70 | screen.expectNone(); | |
| 71 | }); | |
| 72 | ||
| 73 | test("attachToScreen dispose detaches from the root", () => { | |
| 74 | const screen = new testing.MockScreen(); | |
| 75 | const root = new progress.Root(screen); | |
| 76 | const detach = progress.attachToScreen(root, screen); | |
| 77 | ||
| 78 | const a = root.start("hello"); | |
| 79 | screen.expectFrame(1, { merged: "" }); | |
| 80 | screen.expectFrame(0, { | |
| 81 | stderr: testing.MockScreen.sync([ | |
| 82 | `${ansi.style(ansi.fgBlue, "⠋")} hello${ansi.reset}\n`, | |
| 83 | ]), | |
| 84 | }); | |
| 85 | ||
| 86 | detach[Symbol.dispose](); | |
| 87 | // the widget is stopped and removed from the screen on detach | |
| 88 | screen.expectFrame(0, { | |
| 89 | stderr: testing.MockScreen.sync([ | |
| 90 | ansi.cursorUp(1), | |
| 91 | ansi.clearFullLine, | |
| 42 | 92 | ]), |
| 43 | 93 | }); |
| 94 | ||
| 95 | // further progress activity no longer renders | |
| 96 | a.inc(); | |
| 97 | screen.expectFrame(1, { merged: "" }); // root's own debounce still runs | |
| 98 | screen.expectNone(); | |
| 44 | 99 | }); |
| 45 | 100 | |
| 46 | 101 | describe("encodeEventStream", (t) => { |
| ... | ... | @@ -73,6 +128,99 @@ describe("encodeEventStream", (t) => { |
| 73 | 128 | [1], |
| 74 | 129 | ]); |
| 75 | 130 | }); |
| 131 | ||
| 132 | test("end closes the stream and decode resolves", async () => { | |
| 133 | const root = new progress.Root<number>(); | |
| 134 | const a = root.start("task"); | |
| 135 | const handle = progress.decodeEventStream<number>( | |
| 136 | progress.encodeEventStream(root), | |
| 137 | progress.nullNode, | |
| 138 | ); | |
| 139 | a.end(); | |
| 140 | root.end(42); | |
| 141 | assert.equal(await handle, 42); | |
| 142 | }); | |
| 143 | ||
| 144 | test("error rejects the decode promise", async () => { | |
| 145 | const root = new progress.Root(); | |
| 146 | root.start("task"); | |
| 147 | const handle = progress.decodeEventStream( | |
| 148 | progress.encodeEventStream(root), | |
| 149 | progress.nullNode, | |
| 150 | ); | |
| 151 | root.error(new Error("compute exploded")); | |
| 152 | await assert.rejects(handle, /compute exploded/); | |
| 153 | }); | |
| 154 | ||
| 155 | test("custom events pass through", async () => { | |
| 156 | const root = new progress.Root<number, { meow: [string] }>(); | |
| 157 | root.start("task"); | |
| 158 | const handle = progress.decodeEventStream<number, { meow: [string] }>( | |
| 159 | progress.encodeEventStream(root), | |
| 160 | progress.nullNode, | |
| 161 | ); | |
| 162 | const got: string[] = []; | |
| 163 | handle.on("meow", (s) => void got.push(s)); | |
| 164 | root.emit("meow", "purr"); | |
| 165 | root.end(1); | |
| 166 | assert.equal(await handle, 1); | |
| 167 | assert.deepEqual(got, ["purr"]); | |
| 168 | }); | |
| 169 | ||
| 170 | test("a stream that closes without end rejects", async () => { | |
| 171 | const encoded = new ReadableStream<progress.StreamEvent>({ | |
| 172 | start(c) { | |
| 173 | c.enqueue([-1]); | |
| 174 | c.close(); | |
| 175 | }, | |
| 176 | }); | |
| 177 | const handle = progress.decodeEventStream(encoded, progress.nullNode); | |
| 178 | await assert.rejects(handle, /Stream terminated early/); | |
| 179 | }); | |
| 180 | ||
| 181 | test("events after a long idle gap are not throttled", async () => { | |
| 182 | let t = 0; | |
| 183 | const delays: number[] = []; | |
| 184 | const root = new progress.Root({ | |
| 185 | now: () => t, | |
| 186 | delay: (ms) => (delays.push(ms), async.delay(0)), | |
| 187 | }); | |
| 188 | progress.encodeEventStream(root); | |
| 189 | t = 1000; | |
| 190 | // regression: the throttle used to wait `idle + throttleMs` instead of | |
| 191 | // `throttleMs - idle`, so this would schedule a ~1033ms delay | |
| 192 | root.start("x"); | |
| 193 | assert.ok( | |
| 194 | delays.every((ms) => ms <= 1000 / 30 + 1), | |
| 195 | `delays: ${delays.join(", ")}`, | |
| 196 | ); | |
| 197 | }); | |
| 198 | ||
| 199 | test("byte stream round trip with end", async () => { | |
| 200 | const root = new progress.Root<string>(); | |
| 201 | const a = root.start("task", { total: 3 }); | |
| 202 | const handle = progress.decodeByteStream<string>( | |
| 203 | progress.encodeByteStream(root), | |
| 204 | progress.nullNode, | |
| 205 | ); | |
| 206 | a.inc(); | |
| 207 | a.end(); | |
| 208 | root.end("done"); | |
| 209 | assert.equal(await handle, "done"); | |
| 210 | }); | |
| 211 | }); | |
| 212 | ||
| 213 | test("child truncation ends with a newline", () => { | |
| 214 | const root = new progress.Root(); | |
| 215 | const a = root.start("many"); | |
| 216 | for (let i = 0; i < 55; i += 1) a.start(`item ${i}`); | |
| 217 | root.start("after"); | |
| 218 | const lines = ansi.strip(progress.formatAnsi(0, root.active)).split("\n"); | |
| 219 | const trunc = lines.indexOf("└─ [5 more]"); | |
| 220 | assert.notEqual(trunc, -1, "expected a truncation indicator"); | |
| 221 | // regression: the indicator had no trailing newline, so the next top-level | |
| 222 | // node rendered on the same line | |
| 223 | assert.equal(lines[trunc + 1], "⠋ after"); | |
| 76 | 224 | }); |
| 77 | 225 | |
| 78 | 226 | // ## value formatters |
| ... | ... | @@ -294,6 +442,7 @@ type EncodedKey = typeof progress.internals.EncodedKey; |
| 294 | 442 | import assert from "node:assert/strict"; |
| 295 | 443 | import { describe, test, vi } from "vitest"; |
| 296 | 444 | import { UNWRAP } from "./assert.ts"; |
| 445 | import * as async from "./async.ts"; | |
| 297 | 446 | import * as progress from "./progress.ts"; |
| 298 | 447 | import * as stream from "./stream.ts"; |
| 299 | 448 | import * as ansi from "./string/ansi.ts"; |
lib/progress.ts+75-19| ... | ... | @@ -622,12 +622,9 @@ function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) { |
| 622 | 622 | state.children.forEach((child) => endNode(owner, child)); |
| 623 | 623 | state.detached = true; |
| 624 | 624 | globalKeyPool.recycle(state.key); |
| 625 | let title = state.text; | |
| 626 | 625 | if (state.parent) { |
| 627 | 626 | const i = state.parent.children.indexOf(state); |
| 628 | 627 | ASSERT(i !== -1); |
| 629 | let p: Internal | null = state; | |
| 630 | while (p = p.parent) title = p.text + " / " + title; | |
| 631 | 628 | state.parent.children.splice(i, 1); |
| 632 | 629 | } |
| 633 | 630 | owner.emit("node-end", state); |
| ... | ... | @@ -732,7 +729,7 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { |
| 732 | 729 | } |
| 733 | 730 | if (truncated) { |
| 734 | 731 | out += depth.map((x) => x ? box.line : " ").join("") + box.langle; |
| 735 | out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`); | |
| 732 | out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`) + "\n"; | |
| 736 | 733 | } |
| 737 | 734 | return out; |
| 738 | 735 | } |
| ... | ... | @@ -793,8 +790,11 @@ export function attachToScreen( |
| 793 | 790 | format: ({ now }) => formatAnsi(now, root.active), |
| 794 | 791 | }) ?? null; |
| 795 | 792 | if (!widget) return; |
| 796 | widget.fps = items.some((x) => x.showTotal !== false && x.total > 0) | |
| 793 | // scan for spinners and visible estimations | |
| 794 | widget.fps = items.some((x) => !x.hidden && !(x.showTotal !== false && x.total > 0)) | |
| 797 | 795 | ? spinnerFps |
| 796 | : hasEstimate(items) | |
| 797 | ? 1 | |
| 798 | 798 | : null; |
| 799 | 799 | widget.redraw(); |
| 800 | 800 | } else { |
| ... | ... | @@ -817,7 +817,16 @@ export function attachToScreen( |
| 817 | 817 | } |
| 818 | 818 | })); |
| 819 | 819 | |
| 820 | return ts.defer(() => stack[Symbol.dispose]); | |
| 820 | stack.defer(() => { | |
| 821 | widget?.stop(); | |
| 822 | widget = null; | |
| 823 | }); | |
| 824 | ||
| 825 | return ts.defer(() => stack.dispose()); | |
| 826 | } | |
| 827 | ||
| 828 | function hasEstimate(nodes: readonly ReadOnlyNode[]): boolean { | |
| 829 | return nodes.some((n) => n.estimatedTime != null || hasEstimate(n.children)); | |
| 821 | 830 | } |
| 822 | 831 | |
| 823 | 832 | const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n"); |
| ... | ... | @@ -915,7 +924,7 @@ export function encodeEventStream< |
| 915 | 924 | let timer: async.Cancelable<void> | null = null; |
| 916 | 925 | function emitSoon() { |
| 917 | 926 | if (timer || ready.value) return; |
| 918 | const remaining = now() - lastEvent + throttleMs; | |
| 927 | const remaining = lastEvent + throttleMs - now(); | |
| 919 | 928 | if (remaining > 0) { |
| 920 | 929 | (timer = delay(remaining)).then(() => { |
| 921 | 930 | timer = null; |
| ... | ... | @@ -927,6 +936,13 @@ export function encodeEventStream< |
| 927 | 936 | lastEvent = now(); |
| 928 | 937 | } |
| 929 | 938 | } |
| 939 | /** terminal events skip the throttle so the stream ends promptly */ | |
| 940 | function emitNow() { | |
| 941 | timer?.cancel(); | |
| 942 | timer = null; | |
| 943 | ready.value = true; | |
| 944 | lastEvent = now(); | |
| 945 | } | |
| 930 | 946 | stack.defer(() => { |
| 931 | 947 | if (timer) timer.cancel(); |
| 932 | 948 | s.changed.clear(); |
| ... | ... | @@ -959,6 +975,22 @@ export function encodeEventStream< |
| 959 | 975 | s.deleted.add(node); // handle add and remove in same frame |
| 960 | 976 | emitSoon(); |
| 961 | 977 | })); |
| 978 | // custom events pass through the stream verbatim | |
| 979 | stack.use(root.onAny((channel, args) => { | |
| 980 | if (rootEvents.has(channel)) return; | |
| 981 | s.pendingEvents.push([channel as string, ...args as ts.Json[]]); | |
| 982 | emitSoon(); | |
| 983 | })); | |
| 984 | stack.use(root.on("end", (result) => { | |
| 985 | s.pendingEvents.push(["end", result]); | |
| 986 | s.ended = true; | |
| 987 | emitNow(); | |
| 988 | })); | |
| 989 | stack.use(root.on("error", (error) => { | |
| 990 | s.pendingEvents.push(["error", errors.message(error)]); | |
| 991 | s.ended = true; | |
| 992 | emitNow(); | |
| 993 | })); | |
| 962 | 994 | }, |
| 963 | 995 | async pull(controller) { |
| 964 | 996 | await ready.until((x) => x === true); |
| ... | ... | @@ -969,10 +1001,13 @@ export function encodeEventStream< |
| 969 | 1001 | } |
| 970 | 1002 | controller.enqueue(s.getDelta(isFirst)); |
| 971 | 1003 | isFirst = false; |
| 1004 | if (s.ended) { | |
| 1005 | controller.close(); | |
| 1006 | stack.dispose(); | |
| 1007 | } | |
| 972 | 1008 | }, |
| 973 | 1009 | cancel() { |
| 974 | 1010 | stack.dispose(); |
| 975 | (0, performance.now)(); | |
| 976 | 1011 | }, |
| 977 | 1012 | }, { highWaterMark: 0 }); |
| 978 | 1013 | } |
| ... | ... | @@ -1003,11 +1038,10 @@ export function decodeEventStream< |
| 1003 | 1038 | const { value, done } = await reader.read(); |
| 1004 | 1039 | signal.throwIfAborted(); |
| 1005 | 1040 | if (done) break; |
| 1006 | decoder.processEvent(value); | |
| 1041 | if (decoder.processEvent(value)) hasEmittedEnd = true; | |
| 1007 | 1042 | } |
| 1008 | 1043 | ASSERT(hasEmittedEnd, "Stream terminated early."); |
| 1009 | 1044 | } catch (err) { |
| 1010 | console.error(err); | |
| 1011 | 1045 | reader.cancel(err); |
| 1012 | 1046 | if (!signal.aborted) reject(err); |
| 1013 | 1047 | } finally { |
| ... | ... | @@ -1037,6 +1071,10 @@ class Encoder< |
| 1037 | 1071 | rootChildrenUpdated = true; |
| 1038 | 1072 | changed = new Map<ReadOnlyNode, Set<keyof ReadOnlyNode>>(); |
| 1039 | 1073 | deleted = new Set<ReadOnlyNode>(); |
| 1074 | /** custom and terminal ("end"/"error") events awaiting the next delta */ | |
| 1075 | pendingEvents: StreamCustomEvent[] = []; | |
| 1076 | /** set when "end" or "error" was recorded; the stream closes after flushing */ | |
| 1077 | ended = false; | |
| 1040 | 1078 | |
| 1041 | 1079 | constructor( |
| 1042 | 1080 | root: Root<Result, EventMap>, |
| ... | ... | @@ -1119,8 +1157,11 @@ class Encoder< |
| 1119 | 1157 | ); |
| 1120 | 1158 | } |
| 1121 | 1159 | |
| 1160 | payload.push(...this.pendingEvents); | |
| 1161 | ||
| 1122 | 1162 | this.deleted.clear(); |
| 1123 | 1163 | this.changed.clear(); |
| 1164 | this.pendingEvents = []; | |
| 1124 | 1165 | this.rootChildrenUpdated = false; |
| 1125 | 1166 | |
| 1126 | 1167 | return payload; |
| ... | ... | @@ -1182,7 +1223,6 @@ class Decoder< |
| 1182 | 1223 | events: Events<EventMap>; |
| 1183 | 1224 | resolve: (result: Result) => void; |
| 1184 | 1225 | active = new Map<EncodedKey, Node>(); |
| 1185 | children = new Map<Node, Node[]>(); | |
| 1186 | 1226 | pendingStart = new Map<EncodedKey, PendingStart>(); |
| 1187 | 1227 | pendingParents = new Map<EncodedKey, EncodedKey>(); |
| 1188 | 1228 | |
| ... | ... | @@ -1201,7 +1241,8 @@ class Decoder< |
| 1201 | 1241 | this.active.clear(); |
| 1202 | 1242 | } |
| 1203 | 1243 | |
| 1204 | processEvent(event: StreamEvent) { | |
| 1244 | /** returns whether the stream signalled "end"; throws on "error" */ | |
| 1245 | processEvent(event: StreamEvent): boolean { | |
| 1205 | 1246 | event = event.slice(); |
| 1206 | 1247 | |
| 1207 | 1248 | // delete unreferenced nodes first, since they may get re-allocated |
| ... | ... | @@ -1236,6 +1277,8 @@ class Decoder< |
| 1236 | 1277 | if (chunk[0] === "end") { |
| 1237 | 1278 | resolving = true; |
| 1238 | 1279 | resolvingValue = chunk[1]; |
| 1280 | } else if (chunk[0] === "error") { | |
| 1281 | throw new Error(`Progress stream error: ${chunk[1]}`); | |
| 1239 | 1282 | } else { |
| 1240 | 1283 | // custom event |
| 1241 | 1284 | // @ts-expect-error TODO: typescript soundness |
| ... | ... | @@ -1300,13 +1343,21 @@ class Decoder< |
| 1300 | 1343 | } |
| 1301 | 1344 | this.pendingParents.clear(); |
| 1302 | 1345 | |
| 1346 | // validate that every referenced child became active; a failure here | |
| 1347 | // means the stream is desynced | |
| 1303 | 1348 | for (const { key, children } of updateChildren) { |
| 1304 | const node = UNWRAP(this.active.get(key)); | |
| 1305 | this.children.set( | |
| 1306 | node, | |
| 1307 | children.map((id) => UNWRAP(this.active.get(id))), | |
| 1308 | ); | |
| 1349 | UNWRAP(this.active.get(key), () => `parent ${key} not active`); | |
| 1350 | for (const id of children) { | |
| 1351 | UNWRAP(this.active.get(id), () => `child ${id} not active`); | |
| 1352 | } | |
| 1309 | 1353 | } |
| 1354 | ||
| 1355 | if (resolving) { | |
| 1356 | this.reset(); | |
| 1357 | this.resolve(resolvingValue as Result); | |
| 1358 | return true; | |
| 1359 | } | |
| 1360 | return false; | |
| 1310 | 1361 | } |
| 1311 | 1362 | |
| 1312 | 1363 | startRecursive(key: EncodedKey, opts: PendingStart): Ref { |
| ... | ... | @@ -1526,10 +1577,14 @@ export function decodeByteStream< |
| 1526 | 1577 | async start(controller) { |
| 1527 | 1578 | reader = new stream.BufferedReader(encoded.getReader()); |
| 1528 | 1579 | try { |
| 1529 | while (reader) controller.enqueue(await readStreamEvent(reader)); | |
| 1580 | // an event boundary with no further bytes is a clean end of stream | |
| 1581 | while (reader && await reader.ensureAvailableOrFalse(1)) { | |
| 1582 | controller.enqueue(await readStreamEvent(reader)); | |
| 1583 | } | |
| 1584 | if (!cancelled) controller.close(); | |
| 1530 | 1585 | } catch (e) { |
| 1531 | 1586 | if (!cancelled) { |
| 1532 | reader.cancel(e); | |
| 1587 | reader?.cancel(e); | |
| 1533 | 1588 | throw e; |
| 1534 | 1589 | } |
| 1535 | 1590 | } finally { |
| ... | ... | @@ -1802,6 +1857,7 @@ export const internals: { |
| 1802 | 1857 | import { ASSERT, UNWRAP } from "./assert.ts"; |
| 1803 | 1858 | import * as async from "./async.ts"; |
| 1804 | 1859 | import * as bytes from "./bytes.ts"; |
| 1860 | import * as errors from "./error.ts"; | |
| 1805 | 1861 | import { Events } from "./Events.ts"; |
| 1806 | 1862 | import * as log from "./log.ts"; |
| 1807 | 1863 | import * as stack from "./log/stack.ts"; |
lib/string.test.ts+6-4| ... | ... | @@ -3,10 +3,12 @@ test("countNewlines", () => { |
| 3 | 3 | assert.equal(string.countNewlines(""), 0); |
| 4 | 4 | }); |
| 5 | 5 | test("escapeRegExp", () => { |
| 6 | assert.equal( | |
| 7 | string.escapeRegExp("delete * from users where id = $id"), | |
| 8 | "delete \\* from users where id = \\$id", | |
| 9 | ); | |
| 6 | // the exact escape encoding differs between the native `RegExp.escape` | |
| 7 | // (hex escapes) and the fallback (backslashes); assert the behavior | |
| 8 | const input = "delete * from users where id = $id"; | |
| 9 | const re = new RegExp(`^${string.escapeRegExp(input)}$`); | |
| 10 | assert.ok(re.test(input)); | |
| 11 | assert.ok(!re.test("delete x from users where id = yid")); | |
| 10 | 12 | }); |
| 11 | 13 | test("escapeShellArgument", () => { |
| 12 | 14 | // escapeShellArgument leaves it alone when the input is trivial |
lib/string/ansi.ts+5-7| ... | ... | @@ -164,6 +164,8 @@ export const clearToEndOfLine: string = "\x1b[K"; |
| 164 | 164 | export const clearToStartOfLine: string = "\x1b[1K"; |
| 165 | 165 | /** clear the entire line that the cursor is on without moving it. */ |
| 166 | 166 | export const clearFullLine: string = "\x1b[2K"; |
| 167 | /** clear from and including the current cell to the end of the screen. */ | |
| 168 | export const clearToEndOfScreen: string = "\x1b[0J"; | |
| 167 | 169 | |
| 168 | 170 | /** |
| 169 | 171 | * Begin Synchronized Output |
| ... | ... | @@ -257,6 +259,7 @@ export function debugAnsi(text: string): string { |
| 257 | 259 | "clearToEndOfLine", |
| 258 | 260 | "clearToStartOfLine", |
| 259 | 261 | "clearFullLine", |
| 262 | "clearToEndOfScreen", | |
| 260 | 263 | "syncStart", |
| 261 | 264 | "syncEnd", |
| 262 | 265 | "bold", |
| ... | ... | @@ -292,14 +295,9 @@ export function widthInTerminal(str: string): number { |
| 292 | 295 | width += 1; |
| 293 | 296 | continue; |
| 294 | 297 | } |
| 298 | // the remainder of the string is measured with the segmenter, which | |
| 299 | // handles ascii correctly via segmentWidth (controls are zero-width). | |
| 295 | 300 | for (const { segment } of segmenter.segment(str.slice(i))) { |
| 296 | if (segment.length === 1) { | |
| 297 | if (code < 32) continue; | |
| 298 | if (code < 127) { | |
| 299 | width += 1; | |
| 300 | continue; | |
| 301 | } | |
| 302 | } | |
| 303 | 301 | width += segmentWidth(segment); |
| 304 | 302 | } |
| 305 | 303 | return width; |
lib/testing.ts+34-17| ... | ... | @@ -114,7 +114,7 @@ export class SyncPromise<T> implements Promise<T> { |
| 114 | 114 | export class FakeTimers { |
| 115 | 115 | time = 0; |
| 116 | 116 | entries: Array<{ |
| 117 | duration: number; | |
| 117 | deadline: number; | |
| 118 | 118 | resolve: () => void; |
| 119 | 119 | src: stack.Frame[]; |
| 120 | 120 | }> = []; |
| ... | ... | @@ -124,21 +124,23 @@ export class FakeTimers { |
| 124 | 124 | }; |
| 125 | 125 | delay: typeof async.delay = (ms) => { |
| 126 | 126 | const src = stack.capture(2); |
| 127 | let entry: (typeof this.entries)[number]; | |
| 127 | 128 | return async.makeCancelable( |
| 128 | 129 | new SyncPromise((resolve) => { |
| 129 | ASSERT(this.entries.length === 0); | |
| 130 | this.entries.push({ | |
| 131 | duration: ms, | |
| 132 | resolve, | |
| 133 | src, | |
| 134 | }); | |
| 130 | entry = { deadline: this.time + ms, resolve, src }; | |
| 131 | this.entries.push(entry); | |
| 135 | 132 | }), |
| 136 | 133 | () => { |
| 137 | ASSERT(this.entries.length === 1); | |
| 138 | this.entries.length = 0; | |
| 134 | const i = this.entries.indexOf(entry); | |
| 135 | if (i !== -1) this.entries.splice(i, 1); | |
| 139 | 136 | }, |
| 140 | 137 | ); |
| 141 | 138 | }; |
| 139 | ||
| 140 | /** the pending timer that fires soonest */ | |
| 141 | next(): (typeof this.entries)[number] | undefined { | |
| 142 | return this.entries.toSorted((a, b) => a.deadline - b.deadline)[0]; | |
| 143 | } | |
| 142 | 144 | } |
| 143 | 145 | |
| 144 | 146 | export function removeUndefinedKeys(obj: unknown): unknown { |
| ... | ... | @@ -176,12 +178,16 @@ export class MockScreen implements Disposable, log.WidgetHost { |
| 176 | 178 | now: log.WidgetHost["now"]; |
| 177 | 179 | |
| 178 | 180 | hasTerminalLock: null | "locked" | "temporary-unlock" = null; |
| 181 | #sizeObservers = new Set<(size: { columns: number; rows: number }) => void>(); | |
| 179 | 182 | |
| 180 | 183 | static sync(text: string[]): string { |
| 181 | 184 | return ansi.syncStart + text.join("") + ansi.syncEnd; |
| 182 | 185 | } |
| 183 | 186 | |
| 184 | constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) { | |
| 187 | constructor({ temporaryUnlocking, outputSharesScreen }: { | |
| 188 | temporaryUnlocking?: boolean; | |
| 189 | outputSharesScreen?: boolean; | |
| 190 | } = {}) { | |
| 185 | 191 | const callerFile = UNWRAP(stack.capture()[0]); |
| 186 | 192 | const host = log.createTerminalWidgetHost({ |
| 187 | 193 | lockTerminal: () => { |
| ... | ... | @@ -210,8 +216,10 @@ export class MockScreen implements Disposable, log.WidgetHost { |
| 210 | 216 | content, |
| 211 | 217 | }); |
| 212 | 218 | }, |
| 213 | getSize: () => { | |
| 214 | return this; | |
| 219 | observeSize: (callback) => { | |
| 220 | callback({ columns: this.columns, rows: this.rows }); | |
| 221 | this.#sizeObservers.add(callback); | |
| 222 | return () => void this.#sizeObservers.delete(callback); | |
| 215 | 223 | }, |
| 216 | 224 | temporaryUnlock: temporaryUnlocking |
| 217 | 225 | ? () => { |
| ... | ... | @@ -231,6 +239,7 @@ export class MockScreen implements Disposable, log.WidgetHost { |
| 231 | 239 | now: this.timers.now, |
| 232 | 240 | delay: this.timers.delay, |
| 233 | 241 | color: true, |
| 242 | outputSharesScreen, | |
| 234 | 243 | }); |
| 235 | 244 | this.writeOutput = host.writeOutput; |
| 236 | 245 | this.writeError = host.writeError; |
| ... | ... | @@ -240,18 +249,26 @@ export class MockScreen implements Disposable, log.WidgetHost { |
| 240 | 249 | this.now = host.now; |
| 241 | 250 | } |
| 242 | 251 | |
| 252 | /** simulate a terminal resize (SIGWINCH) */ | |
| 253 | resize(columns: number, rows: number = this.rows) { | |
| 254 | this.columns = columns; | |
| 255 | this.rows = rows; | |
| 256 | this.#sizeObservers.forEach((cb) => cb({ columns, rows })); | |
| 257 | } | |
| 258 | ||
| 243 | 259 | expectNone() { |
| 244 | 260 | ASSERT(this.timers.entries.length === 0); |
| 245 | 261 | } |
| 246 | 262 | |
| 247 | 263 | expectWithoutConsume(ms: number) { |
| 248 | 264 | const wait = UNWRAP( |
| 249 | this.timers.entries[0], | |
| 265 | this.timers.next(), | |
| 250 | 266 | () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o", |
| 251 | 267 | ); |
| 268 | const duration = wait.deadline - this.timers.time; | |
| 252 | 269 | ASSERT( |
| 253 | ms === wait.duration, | |
| 254 | `expected ${ms}ms to pass, got ${wait.duration}, from:\n${ | |
| 270 | ms === duration, | |
| 271 | `expected ${ms}ms to pass, got ${duration}, from:\n${ | |
| 255 | 272 | wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n") |
| 256 | 273 | }`, |
| 257 | 274 | ); |
| ... | ... | @@ -265,8 +282,8 @@ export class MockScreen implements Disposable, log.WidgetHost { |
| 265 | 282 | }) { |
| 266 | 283 | if (ms != null) { |
| 267 | 284 | const wait = this.expectWithoutConsume(ms); |
| 268 | this.timers.entries.shift(); | |
| 269 | this.timers.time += wait.duration; | |
| 285 | this.timers.entries.splice(this.timers.entries.indexOf(wait), 1); | |
| 286 | this.timers.time = wait.deadline; | |
| 270 | 287 | wait.resolve(); |
| 271 | 288 | } else { |
| 272 | 289 | ASSERT(this.timers.entries.length === 0); |