Files
eCert-MBIP/vendor/laravel/prompts/src/Task.php

391 lines
10 KiB
PHP

<?php
namespace Laravel\Prompts;
use Closure;
use Laravel\Prompts\Support\Logger;
use Laravel\Prompts\Themes\Default\Concerns\InteractsWithStrings;
use RuntimeException;
class Task extends Prompt
{
use InteractsWithStrings;
/**
* The minimum width for the longest line calculation.
*/
protected int $minWidth = 0;
/**
* How long to wait between rendering each frame.
*/
public int $interval = 100;
/**
* The number of times the task has been rendered.
*/
public int $count = 0;
/**
* Whether the task can only be rendered once.
*/
public bool $static = false;
/**
* The process ID after forking.
*/
protected int $pid;
/**
* The socket for IPC communication.
*
* @var resource|null
*/
protected $socket;
/**
* Pre-wrapped log lines for the scrolling output area.
*
* @var array<int, string>
*/
public array $logs = [];
/**
* Stable status messages (success, warning, error).
*
* @var list<array{type: string, message: string}>
*/
public array $stableMessages = [];
/**
* The maximum number of stable messages to display.
*/
public int $maxStableMessages = 10;
/**
* The identifier for the task.
*/
public string $identifier = '';
/**
* Whether the task has finished.
*/
public bool $finished = false;
/**
* Buffer for incomplete lines from non-blocking socket reads.
*/
protected string $buffer = '';
/**
* The log index where the current partial started, or null if not streaming.
*/
protected ?int $partialStartIndex = null;
/**
* Create a new Task instance.
*/
public function __construct(
public string $label = '',
public int $limit = 10,
public bool $keepSummary = false,
public ?string $subLabel = null,
) {
$this->identifier = uniqid();
}
/**
* Render the task and execute the callback.
*
* @template TReturn of mixed
*
* @param Closure(Logger): TReturn $callback
* @return TReturn
*/
public function run(Closure $callback): mixed
{
$this->limit = min($this->limit, $this->terminal()->lines() - 10);
$this->recalculateMaxStableMessages();
$this->capturePreviousNewLines();
if (! function_exists('pcntl_fork')) {
return $this->renderStatically($callback);
}
$originalAsync = pcntl_async_signals(true);
pcntl_signal(SIGINT, fn () => exit());
try {
$this->hideCursor();
$this->render();
$sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
if ($sockets === false) {
return $this->renderStatically($callback);
}
$this->pid = pcntl_fork();
if ($this->pid === 0) {
fclose($sockets[1]);
$childSocket = $sockets[0];
stream_set_blocking($childSocket, false);
while (true) { // @phpstan-ignore-line
$this->receiveMessages($childSocket);
if (! $this->finished) {
$this->render();
$this->count++;
}
usleep($this->interval * 1000);
}
} else {
fclose($sockets[0]);
$this->socket = $sockets[1];
$logger = new Logger($this->identifier, $this->socket);
$result = $callback($logger);
if ($this->socket !== null) {
// Send a reset message to the parent process to reset the terminal.
fwrite($this->socket, $this->identifier.'_'.'reset:'.($originalAsync ? 1 : 0).PHP_EOL);
usleep($this->interval * 2000);
}
return $result;
}
} catch (\Throwable $e) {
$this->resetTerminal($originalAsync);
throw $e;
}
}
/**
* Receive and process messages from the parent process.
*
* @param resource $socket
*/
protected function receiveMessages($socket): void
{
$prefix = preg_quote($this->identifier, '/');
while (($data = fgets($socket)) !== false) {
// Buffer incomplete lines from non-blocking reads.
if (! str_ends_with($data, PHP_EOL)) {
$this->buffer .= $data;
continue;
}
$line = rtrim($this->buffer.$data, PHP_EOL);
$this->buffer = '';
if ($line === '') {
continue;
}
// Check for typed messages: {id}_{type}:{content}
if (preg_match('/^'.$prefix.'_(success|warning|error|label|sublabel|reset|partial|commitpartial):(.*)/', $line, $matches)) {
$type = $matches[1];
$content = $matches[2];
if ($type === 'reset') {
$this->resetTerminal((bool) $content);
continue;
}
if ($type === 'partial') {
$this->replacePartialLines($content);
continue;
}
if ($type === 'commitpartial') {
$this->partialStartIndex = null;
continue;
}
if ($type === 'label') {
$this->label = $content;
} elseif ($type === 'sublabel') {
$this->subLabel = $content;
$this->recalculateMaxStableMessages();
} else {
$this->stableMessages[] = ['type' => $type, 'message' => $content];
$this->logs = [];
$this->partialStartIndex = null;
}
while (count($this->stableMessages) > $this->maxStableMessages) {
array_shift($this->stableMessages);
}
continue;
}
// Regular log line — strip cursor-reset control sequences.
$line = preg_replace('/\e\[(?:1)?G\e\[2K/', '', $line);
// Wrap and add to ring buffer.
$this->addLogLines($line);
}
}
/**
* Wrap a log line and append to the ring buffer, trimming to the limit.
*/
protected function addLogLines(string $line): void
{
$width = $this->terminal()->cols() - 10;
$plainText = $this->stripEscapeSequences($line);
if (mb_strwidth($plainText) > $width) {
$wrapped = $this->ansiWordwrap($line, $width);
} else {
$wrapped = [$line];
}
array_push($this->logs, ...$wrapped);
while (count($this->logs) > $this->limit) {
array_shift($this->logs);
}
}
/**
* Replace the in-progress partial lines with the full accumulated text.
*/
protected function replacePartialLines(string $text): void
{
if ($this->partialStartIndex === null) {
$this->partialStartIndex = count($this->logs);
}
// Truncate back to where the partial started.
$this->logs = array_slice($this->logs, 0, $this->partialStartIndex);
// Wrap and append the full accumulated partial text.
$width = $this->terminal()->cols() - 10;
$plainText = $this->stripEscapeSequences($text);
if (mb_strwidth($plainText) > $width) {
$wrapped = $this->ansiWordwrap($text, $width);
} else {
$wrapped = [$text];
}
array_push($this->logs, ...$wrapped);
while (count($this->logs) > $this->limit) {
array_shift($this->logs);
$this->partialStartIndex = max(0, $this->partialStartIndex - 1);
}
}
/**
* Recompute the stable-message budget based on the current sub-label state.
*/
protected function recalculateMaxStableMessages(): void
{
$reserved = 2 + ($this->subLabel !== null && $this->subLabel !== '' ? 1 : 0);
$this->maxStableMessages = max(0, $this->terminal()->lines() - 10 - $this->limit - $reserved);
}
/**
* Reset the terminal.
*/
protected function resetTerminal(bool $originalAsync): void
{
$this->finished = true;
pcntl_async_signals($originalAsync);
pcntl_signal(SIGINT, SIG_DFL);
if ($this->socket !== null) {
fclose($this->socket);
$this->socket = null;
}
if ($this->keepSummary && count($this->stableMessages) > 0) {
$this->render();
return;
}
$this->eraseRenderedLines();
}
/**
* Render a static version of the task.
*
* @template TReturn of mixed
*
* @param Closure(Logger): TReturn $callback
* @return TReturn
*/
protected function renderStatically(Closure $callback): mixed
{
$this->static = true;
try {
$this->hideCursor();
$this->render();
$logger = new Logger($this->identifier);
$result = $callback($logger);
} finally {
$this->eraseRenderedLines();
}
return $result;
}
/**
* Disable prompting for input.
*
* @throws RuntimeException
*/
public function prompt(): never
{
throw new RuntimeException('Task cannot be prompted.');
}
/**
* Get the current value of the prompt.
*/
public function value(): bool
{
return true;
}
/**
* Clear the lines rendered by the task.
*/
protected function eraseRenderedLines(): void
{
$lines = explode(PHP_EOL, $this->prevFrame);
$this->moveCursor(-999, -count($lines) + 1);
$this->eraseDown();
}
/**
* Clean up after the task.
*/
public function __destruct()
{
if (! empty($this->pid)) {
posix_kill($this->pid, SIGHUP);
}
parent::__destruct();
}
}