From 5de572c5b348913a54d971a215cb673e765a0be4 Mon Sep 17 00:00:00 2001 From: Joe Tannenbaum Date: Wed, 19 Aug 2026 14:59:46 -0700 Subject: [PATCH] swap dynamic spinner for task --- app/Commands/CommandRun.php | 13 +- app/Commands/Deploy.php | 12 +- app/Commands/Ship.php | 13 +- app/Concerns/UpdatesBuildDeployCommands.php | 7 +- app/Prompts/DynamicSpinner.php | 291 -------------------- app/Prompts/SpinnerRenderer.php | 24 +- app/Prompts/TaskRenderer.php | 148 ++++++++++ app/Providers/AppServiceProvider.php | 4 +- app/helpers.php | 8 - tests/Unit/TaskRendererTest.php | 114 ++++++++ 10 files changed, 292 insertions(+), 342 deletions(-) delete mode 100644 app/Prompts/DynamicSpinner.php create mode 100644 app/Prompts/TaskRenderer.php create mode 100644 tests/Unit/TaskRendererTest.php diff --git a/app/Commands/CommandRun.php b/app/Commands/CommandRun.php index 12870a53..01b94156 100644 --- a/app/Commands/CommandRun.php +++ b/app/Commands/CommandRun.php @@ -19,6 +19,7 @@ use function Laravel\Prompts\autocomplete; use function Laravel\Prompts\intro; use function Laravel\Prompts\select; +use function Laravel\Prompts\task; class CommandRun extends BaseCommand { @@ -122,14 +123,14 @@ protected function runCommandOnEnvironment(Environment $environment): Command 'cmd', ); - return dynamicSpinner( - fn () => $this->client->commands()->run( + return task( + label: 'Running command...', + callback: fn () => $this->client->commands()->run( new RunCommandRequestData( environmentId: $environment->id, command: $this->form()->get('command'), ), ), - 'Running command...', ); } @@ -252,15 +253,15 @@ protected function localArtisanCommands(?Application $application): ?array protected function selectFromHistory(string $environmentId): ?string { - $recentCommands = dynamicSpinner( - fn () => $this->client->commands()->list($environmentId) + $recentCommands = task( + label: 'Loading command history...', + callback: fn () => $this->client->commands()->list($environmentId) ->collect() ->map(fn ($cmd) => $cmd->command) ->unique() ->take(10) ->values() ->collect(), - 'Loading command history...', ); if ($recentCommands->isEmpty()) { diff --git a/app/Commands/Deploy.php b/app/Commands/Deploy.php index d7254242..12ac1bf2 100644 --- a/app/Commands/Deploy.php +++ b/app/Commands/Deploy.php @@ -11,11 +11,13 @@ use Carbon\CarbonInterval; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Sleep; +use Laravel\Prompts\Support\Logger; use function Laravel\Prompts\confirm; use function Laravel\Prompts\error; use function Laravel\Prompts\intro; use function Laravel\Prompts\outro; +use function Laravel\Prompts\task; use function Laravel\Prompts\warning; class Deploy extends BaseCommand @@ -87,9 +89,9 @@ public function handle() return self::SUCCESS; } - dynamicSpinner( - fn (callable $updateMessage) => $this->updateDeploymentStatus($deployment, $updateMessage), - $this->getDeploymentMessage($deployment), + task( + label: $this->getDeploymentMessage($deployment), + callback: fn (Logger $log) => $this->updateDeploymentStatus($deployment, $log), ); $deployment = $this->client->deployments()->get($deployment->id); @@ -140,7 +142,7 @@ public function handle() outro($environment->url); } - protected function updateDeploymentStatus(Deployment $deployment, callable $updateMessage): void + protected function updateDeploymentStatus(Deployment $deployment, Logger $log): void { $checkApi = true; $count = 0; @@ -164,7 +166,7 @@ protected function updateDeploymentStatus(Deployment $deployment, callable $upda ])); } - $updateMessage($newMessage, $lastMessage !== $deploymentStatus->status->monitorLabel()); + $log->label($newMessage); $lastMessage = $deploymentStatus->status->monitorLabel(); diff --git a/app/Commands/Ship.php b/app/Commands/Ship.php index dada83f4..dd42ca47 100644 --- a/app/Commands/Ship.php +++ b/app/Commands/Ship.php @@ -42,6 +42,7 @@ use function Laravel\Prompts\outro; use function Laravel\Prompts\select; use function Laravel\Prompts\spin; +use function Laravel\Prompts\task; use function Laravel\Prompts\text; use function Laravel\Prompts\warning; @@ -296,15 +297,15 @@ protected function createApplication(string $defaultRegion, string $repository): }), ); - return dynamicSpinner( - fn () => $this->client->applications()->create( + return task( + label: 'Creating application', + callback: fn () => $this->client->applications()->create( new CreateApplicationRequestData( repository: $repository, name: $this->form()->get('name'), region: $this->form()->get('region'), ), ), - 'Creating application', ); } @@ -813,8 +814,9 @@ protected function pushCustomEnvironmentVariables(Application $application): voi $varsToAdd = collect($varsToAdd)->map(fn ($key) => ['key' => $key, 'value' => $variables[$key]]); - dynamicSpinner( - function () use ($application, $varsToAdd) { + task( + label: 'Adding selected variables to Cloud environment', + callback: function () use ($application, $varsToAdd) { while (count($application->environmentIds) === 0) { $application = $this->client->applications()->withDefaultIncludes()->get($application->id); Sleep::for(CarbonInterval::seconds(1)); @@ -827,7 +829,6 @@ function () use ($application, $varsToAdd) { ), ); }, - 'Adding selected variables to Cloud environment', ); } } diff --git a/app/Concerns/UpdatesBuildDeployCommands.php b/app/Concerns/UpdatesBuildDeployCommands.php index 5004391f..915b74c1 100644 --- a/app/Concerns/UpdatesBuildDeployCommands.php +++ b/app/Concerns/UpdatesBuildDeployCommands.php @@ -5,6 +5,7 @@ use App\Client\Requests\UpdateEnvironmentRequestData; use App\Dto\Environment; +use function Laravel\Prompts\task; use function Laravel\Prompts\textarea; trait UpdatesBuildDeployCommands @@ -25,13 +26,13 @@ protected function updateCommands(Environment $environment): void $this->loopUntilValid( function () use ($environment, $buildCommand, $deployCommand) { - return dynamicSpinner( - fn () => $this->client->environments()->update(new UpdateEnvironmentRequestData( + return task( + label: 'Updating commands', + callback: fn () => $this->client->environments()->update(new UpdateEnvironmentRequestData( environmentId: $environment->id, buildCommand: $buildCommand, deployCommand: $deployCommand, )), - 'Updating commands', ); }, ); diff --git a/app/Prompts/DynamicSpinner.php b/app/Prompts/DynamicSpinner.php deleted file mode 100644 index 59fb7e4f..00000000 --- a/app/Prompts/DynamicSpinner.php +++ /dev/null @@ -1,291 +0,0 @@ -lastMessage = $message; - $this->resetIdentifier = str()->random(10); - } - - /** - * Render the spinner and execute the callback. - * - * @template TReturn of mixed - * - * @param Closure(callable(string): void): TReturn $callback - * @return TReturn - */ - public function spin(Closure $callback): mixed - { - $this->capturePreviousNewLines(); - - if (! function_exists('pcntl_fork')) { - return $this->renderStatically($callback); - } - - $originalAsync = pcntl_async_signals(true); - - pcntl_signal(SIGINT, fn () => exit()); - - $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); - - if ($sockets === false) { - return $this->renderStatically($callback); - } - - $this->sockets = $sockets; - - try { - $this->hideCursor(); - $this->render(); - - $this->pid = pcntl_fork(); - - if ($this->pid === 0) { - fclose($this->sockets[0]); - stream_set_blocking($this->sockets[1], false); - - while (true) { // @phpstan-ignore while.alwaysTrue - $this->checkForMessageUpdate(); - $this->render(); - - $this->count++; - - if ($this->count % 10 === 0) { - $this->ellipsisCount++; - } - - if (! str($this->stripEscapeSequences($this->message))->endsWith(['.', '!'])) { - $this->displayMessage = $this->message.$this->dim($this->ellipsisFrames[$this->ellipsisCount % count($this->ellipsisFrames)]); - } else { - $this->displayMessage = $this->message; - } - - usleep($this->interval * 1000); - } - } else { - fclose($this->sockets[1]); - - $result = $callback($this->createMessageUpdater()); - - $this->resetTerminal($originalAsync); - - return $result; - } - } catch (Throwable $e) { - $this->resetTerminal($originalAsync); - - throw $e; - } - } - - /** - * Create a callback that can be used to update the spinner message. - */ - protected function createMessageUpdater(): Closure - { - return function (string $message, bool $resetEllipsis = false): void { - if ($this->sockets !== null && is_resource($this->sockets[0])) { - fwrite($this->sockets[0], $message.($resetEllipsis ? $this->resetIdentifier : '')."\x00"); - } - }; - } - - /** - * Check for message updates from the parent process. - */ - protected function checkForMessageUpdate(): void - { - if ($this->sockets === null || ! is_resource($this->sockets[1])) { - return; - } - - $data = ''; - - while (($chunk = fread($this->sockets[1], 1024)) !== false && $chunk !== '') { - $data .= $chunk; - } - - if ($data !== '') { - $messages = explode("\x00", $data); - $lastMessage = ''; - - foreach (array_reverse($messages) as $msg) { - if ($msg !== '') { - $lastMessage = $msg; - - break; - } - } - - if ($lastMessage !== '') { - if (str($lastMessage)->endsWith($this->resetIdentifier)) { - $this->ellipsisCount = 0; - $lastMessage = str($lastMessage)->beforeLast($this->resetIdentifier)->toString(); - } - - $this->message = $lastMessage; - } - } - } - - /** - * Reset the terminal. - */ - protected function resetTerminal(bool $originalAsync): void - { - pcntl_async_signals($originalAsync); - pcntl_signal(SIGINT, SIG_DFL); - - $this->killChildProcess(); - $this->closeSockets(); - $this->eraseRenderedLines(); - } - - /** - * Kill the child process if it exists. - */ - protected function killChildProcess(): void - { - if (! empty($this->pid) && $this->pid > 0) { - posix_kill($this->pid, SIGHUP); - pcntl_waitpid($this->pid, $status, WNOHANG); - } - } - - /** - * Close socket connections. - */ - protected function closeSockets(): void - { - if ($this->sockets !== null) { - foreach ($this->sockets as $socket) { - if (is_resource($socket)) { - fclose($socket); - } - } - - $this->sockets = null; - } - } - - /** - * Render a static version of the spinner. - * - * @template TReturn of mixed - * - * @param Closure(callable(string): void): TReturn $callback - * @return TReturn - */ - protected function renderStatically(Closure $callback): mixed - { - $this->static = true; - - $noopUpdater = function (string $message): void { - $this->message = $message; - }; - - try { - $this->hideCursor(); - $this->render(); - - $result = $callback($noopUpdater); - } finally { - $this->eraseRenderedLines(); - } - - return $result; - } - - /** - * Disable prompting for input. - * - * @throws RuntimeException - */ - public function prompt(): never - { - throw new RuntimeException('Spinner cannot be prompted.'); - } - - /** - * Get the current value of the prompt. - */ - public function value(): bool - { - return true; - } - - /** - * Clear the lines rendered by the spinner. - */ - protected function eraseRenderedLines(): void - { - $lines = explode(PHP_EOL, $this->prevFrame); - $this->moveCursor(-999, -count($lines) + 1); - $this->eraseDown(); - } - - /** - * Clean up after the spinner. - */ - public function __destruct() - { - $this->killChildProcess(); - $this->closeSockets(); - - parent::__destruct(); - } -} diff --git a/app/Prompts/SpinnerRenderer.php b/app/Prompts/SpinnerRenderer.php index 55c654b0..49345f70 100644 --- a/app/Prompts/SpinnerRenderer.php +++ b/app/Prompts/SpinnerRenderer.php @@ -2,31 +2,17 @@ namespace App\Prompts; +use Laravel\Prompts\Concerns\HasSpinner; use Laravel\Prompts\Spinner; class SpinnerRenderer extends Renderer { - /** - * The frames of the spinner. - * - * @var array - */ - protected array $frames = ['⠂', '⠒', '⠐', '⠰', '⠠', '⠤', '⠄', '⠆']; - - /** - * The frame to render when the spinner is static. - */ - protected string $staticFrame = '⠶'; - - /** - * The interval between frames. - */ - protected int $interval = 75; + use HasSpinner; /** * Render the spinner. */ - public function __invoke(Spinner|DynamicSpinner $spinner): string + public function __invoke(Spinner $spinner): string { if ($spinner->static) { return $this->line("{$this->cyan($this->staticFrame)} {$spinner->message}"); @@ -34,8 +20,6 @@ public function __invoke(Spinner|DynamicSpinner $spinner): string $spinner->interval = $this->interval; - $frame = $this->frames[$spinner->count % count($this->frames)]; - - return $this->line("{$this->cyan($frame)} ".($spinner->displayMessage ?? $spinner->message)); + return $this->line("{$this->cyan($this->spinnerFrame($spinner->count))} {$spinner->message}"); } } diff --git a/app/Prompts/TaskRenderer.php b/app/Prompts/TaskRenderer.php new file mode 100644 index 00000000..90b69240 --- /dev/null +++ b/app/Prompts/TaskRenderer.php @@ -0,0 +1,148 @@ + + */ + protected array $ellipsisFrames = ['', '.', '..', '...', '...']; + + /** + * How many spinner frames each ellipsis frame lasts for. + */ + protected int $framesPerEllipsis = 10; + + /** + * Render the task. + */ + public function __invoke(Task $task): string + { + if ($task->static) { + return $this->line("{$this->cyan($this->staticFrame)} {$task->label}"); + } + + $task->interval = $this->interval; + + if ($task->finished) { + return $this->summary($task); + } + + $this->line("{$this->cyan($this->spinnerFrame($task->count))} {$task->label}{$this->ellipsis($task)}"); + + $this->subLabel($task); + $this->messages($task); + $this->logs($task); + + return $this; + } + + /** + * The frame left on screen by a finished task that keeps its summary. + */ + protected function summary(Task $task): string + { + // The timeline carries on below a finished task rather than closing off with a corner. + $task->state = 'submit'; + + $this->bullet($task->label); + $this->messages($task); + + return $this; + } + + /** + * A label that already ends in punctuation reads as finished, so leave it be. + */ + protected function ellipsis(Task $task): string + { + if (str($this->stripEscapeSequences($task->label))->endsWith(['.', '!'])) { + return ''; + } + + $frame = intdiv($task->count, $this->framesPerEllipsis) % count($this->ellipsisFrames); + + return $this->dim($this->ellipsisFrames[$frame]); + } + + /** + * The dim line under the label, for what the task is doing right now. + */ + protected function subLabel(Task $task): void + { + if ($task->subLabel === null || $task->subLabel === '') { + return; + } + + $this->lineWithBorder($this->dim($this->truncate($task->subLabel, $this->maxWidth()))); + } + + /** + * The successes, warnings and errors the task has reported. Task caps how many it + * keeps, so everything it still holds is meant to be on screen. + */ + protected function messages(Task $task): void + { + if ($task->stableMessages === []) { + return; + } + + $this->lineWithBorder(''); + + foreach ($task->stableMessages as $message) { + $symbol = $this->messageSymbol($message['type']); + $color = $symbol->color(); + + $this->lineWithBorder( + $this->{$color}($symbol->value).' '.$this->truncate($message['message'], $this->maxWidth() - 3), + ); + } + } + + /** + * The task's window onto its own output. Task wraps and trims these to fit, and the + * window is padded to its full height so the lines below it hold still. + */ + protected function logs(Task $task): void + { + if ($task->logs === []) { + return; + } + + $this->lineWithBorder(''); + + foreach ($task->logs as $log) { + $this->lineWithBorder($this->dim($log)); + } + + for ($padding = $task->limit - count($task->logs); $padding > 0; $padding--) { + $this->lineWithBorder(''); + } + } + + protected function messageSymbol(string $type): TimelineSymbol + { + return match ($type) { + 'success' => TimelineSymbol::SUCCESS, + 'error' => TimelineSymbol::FAILURE, + 'warning' => TimelineSymbol::WARNING, + default => TimelineSymbol::DOT, + }; + } + + protected function maxWidth(): int + { + return $this->prompt->terminal()->cols() - 6; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 39ed97f2..331f75cf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -7,9 +7,7 @@ use App\Middleware\RequiresAuthToken; use App\Middleware\SuppressOutputIfJson; use App\Prompts\Answered; -use App\Prompts\DynamicSpinner; use App\Prompts\Renderer as PromptRenderer; -use App\Prompts\SpinnerRenderer; use App\Prompts\TextPromptRenderer; use Illuminate\Console\Events\CommandFinished; use Illuminate\Console\Events\CommandStarting; @@ -45,6 +43,7 @@ public function boot(): void 'SlideInRenderer', 'SpinnerRenderer', 'TableRenderer', + 'TaskRenderer', 'CodeBlockRenderer', 'TextareaPromptRenderer', 'TextPromptRenderer', @@ -65,7 +64,6 @@ public function boot(): void }); $renderers->offsetSet(Answered::class, TextPromptRenderer::class); - $renderers->offsetSet(DynamicSpinner::class, SpinnerRenderer::class); Prompt::addTheme('cloud', $renderers->toArray()); Prompt::theme('cloud'); diff --git a/app/helpers.php b/app/helpers.php index cd050a56..d098cdc9 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -4,7 +4,6 @@ use App\Prompts\CodeBlock; use App\Prompts\DataList; use App\Prompts\DataTable; -use App\Prompts\DynamicSpinner; use App\Prompts\SlideIn; use Illuminate\Support\Facades\Process; use Laravel\Prompts\Note; @@ -30,13 +29,6 @@ function slideIn(string $message): void } } -if (! function_exists('dynamicSpinner')) { - function dynamicSpinner(callable $callback, string $message): mixed - { - return (new DynamicSpinner(message: $message))->spin($callback); - } -} - if (! function_exists('dataList')) { function dataList(array $data): void { diff --git a/tests/Unit/TaskRendererTest.php b/tests/Unit/TaskRendererTest.php new file mode 100644 index 00000000..be42b5e9 --- /dev/null +++ b/tests/Unit/TaskRendererTest.php @@ -0,0 +1,114 @@ + Renderer::$suppressOutput = false); + +/** + * The rendered frame with its colour codes stripped, so assertions can read the text. + */ +function renderTask(string $label, int $count = 0, bool $static = false, ?callable $configure = null): string +{ + $task = new Task($label); + $task->count = $count; + $task->static = $static; + + if ($configure) { + $configure($task); + } + + return preg_replace('/\e\[[0-9;]*m/', '', (new TaskRenderer($task))($task)); +} + +/** + * The rendered frame as lines, with the timeline borders and padding stripped off. + * + * @return array + */ +function taskLines(?callable $configure = null, string $label = 'Creating application'): array +{ + $frame = renderTask($label, configure: $configure); + + return collect(explode(PHP_EOL, $frame)) + ->map(fn (string $line) => trim(str_replace('│', '', $line))) + ->filter(fn (string $line) => $line !== '' && $line !== '╰') + ->values() + ->all(); +} + +it('cycles a trailing ellipsis while the task runs', function () { + expect(renderTask('Creating application', 0))->toContain('Creating application') + ->and(renderTask('Creating application', 0))->not->toContain('.') + ->and(renderTask('Creating application', 10))->toContain('Creating application.') + ->and(renderTask('Creating application', 20))->toContain('Creating application..') + ->and(renderTask('Creating application', 30))->toContain('Creating application...') + ->and(renderTask('Creating application', 50))->not->toContain('.'); +}); + +it('leaves a label that already ends in punctuation alone', function (string $label) { + foreach ([0, 10, 20, 30] as $count) { + expect(renderTask($label, $count))->toContain($label) + ->and(renderTask($label, $count))->not->toContain($label.'.'); + } +})->with(['Running command...', 'Building!']); + +it('ignores escape sequences when checking the label for punctuation', function () { + expect(renderTask("\e[2m00:07\e[22m Deploying!", 10))->not->toContain('Deploying!.'); +}); + +it('renders a single static frame when the task cannot animate', function () { + expect(renderTask('Creating application', static: true))->toContain('⠶ Creating application'); +}); + +it('renders nothing beyond the label when the task reports nothing', function () { + expect(taskLines())->toBe(['⠂ Creating application']); +}); + +it('renders a sub-label under the label', function () { + expect(taskLines(fn (Task $task) => $task->subLabel = 'Waiting for the build')) + ->toBe(['⠂ Creating application', 'Waiting for the build']); +}); + +it('renders reported messages with a symbol for each type', function () { + $lines = taskLines(function (Task $task) { + $task->stableMessages = [ + ['type' => 'success', 'message' => 'Repository cloned'], + ['type' => 'warning', 'message' => 'No build cache'], + ['type' => 'error', 'message' => 'Assets failed'], + ]; + }); + + expect($lines)->toBe([ + '⠂ Creating application', + '✔ Repository cloned', + '▲ No build cache', + '✘ Assets failed', + ]); +}); + +it('renders logged output and holds the window open at its full height', function () { + $logging = function (Task $task) { + $task->limit = 5; + $task->logs = ['npm install', 'vite build']; + }; + + expect(taskLines($logging))->toBe(['⠂ Creating application', 'npm install', 'vite build']); + + // Label, blank spacer, two logs, three lines of padding, plus the timeline borders. + $frame = rtrim(renderTask('Creating application', configure: $logging), PHP_EOL); + + expect(substr_count($frame, PHP_EOL))->toBe(8); +}); + +it('closes the timeline when a finished task keeps its summary', function () { + $finished = function (Task $task) { + $task->finished = true; + $task->stableMessages = [['type' => 'success', 'message' => 'Application created']]; + }; + + expect(taskLines($finished))->toBe(['• Creating application', '✔ Application created']) + ->and(renderTask('Creating application', configure: $finished))->not->toContain('╰'); +});