Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(console): add task component #857

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@
"tempest/view": "self.version",
"tempest/vite": "self.version"
},
"suggest": {
"ext-pcntl": "Required to use some interactive console components."
},
"minimum-stability": "dev",
"prefer-stable": true,
"autoload": {
Expand Down
8 changes: 8 additions & 0 deletions src/Tempest/Console/src/Components/ComponentState.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,12 @@ enum ComponentState
* Input is blocked.
*/
case BLOCKED;

public function isFinished(): bool
{
return match ($this) {
self::ACTIVE, self::ERROR, self::BLOCKED => false,
default => true,
};
}
}
186 changes: 186 additions & 0 deletions src/Tempest/Console/src/Components/Interactive/TaskComponent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
<?php

declare(strict_types=1);

namespace Tempest\Console\Components\Interactive;

use Closure;
use Generator;
use RuntimeException;
use Symfony\Component\Process\Process;
use Tempest\Console\Components\ComponentState;
use Tempest\Console\Components\Concerns\HasErrors;
use Tempest\Console\Components\Concerns\HasState;
use Tempest\Console\Components\Renderers\SpinnerRenderer;
use Tempest\Console\Components\Renderers\TaskRenderer;
use Tempest\Console\InteractiveConsoleComponent;
use Tempest\Console\Terminal\Terminal;
use Throwable;
use function Tempest\Support\arr;

final class TaskComponent implements InteractiveConsoleComponent
{
use HasErrors;
use HasState;

private TaskRenderer $renderer;

private int $processId;

private float $startedAt;

private ?float $finishedAt = null;

private array $sockets;

private array $log = [];

private(set) array $extensions = ['pcntl'];

public function __construct(
readonly string $label,
private null|Process|Closure $handler = null,
) {
$this->handler = $this->resolveHandler($handler);
$this->renderer = new TaskRenderer(new SpinnerRenderer(), $label);
$this->startedAt = hrtime(as_number: true);
}

public function render(Terminal $terminal): Generator
{
// If there is no task handler, we don't need to fork the process, as
// it is a time-consuming operation. We can simply consider it done.
if ($this->handler === null) {
$this->state = ComponentState::SUBMITTED;

yield $this->renderTask($terminal);

return true;
}

$this->sockets = stream_socket_pair(domain: STREAM_PF_UNIX, type: STREAM_SOCK_STREAM, protocol: STREAM_IPPROTO_IP);
$this->processId = pcntl_fork();
innocenzi marked this conversation as resolved.
Show resolved Hide resolved

if ($this->processId === -1) {
throw new RuntimeException('Could not fork process');
}

if (! $this->processId) {
$this->executeHandler();
}

try {
fclose($this->sockets[0]);
stream_set_blocking($this->sockets[1], enable: false);

while (true) {
// The process is still running, so we continue looping.
if (pcntl_waitpid($this->processId, $status, flags: WNOHANG) === 0) {
yield $this->renderTask(
terminal: $terminal,
line: fread($this->sockets[1], length: 1024) ?: null,
);

usleep($this->renderer->delay());

continue;
}

// The process is done, we register the finishing timestamp,
// close the communication socket and determine the finished state.
fclose($this->sockets[1]);
$this->finishedAt = hrtime(as_number: true);
$this->state = match (pcntl_wifexited($status)) {
true => match (pcntl_wexitstatus($status)) {
0 => ComponentState::SUBMITTED,
default => ComponentState::ERROR,
},
default => ComponentState::CANCELLED,
};

yield $this->renderTask($terminal);

return $this->state === ComponentState::SUBMITTED;
}
} finally {
if ($this->state->isFinished() && $this->processId) {
posix_kill($this->processId, SIGTERM);
}

$this->cleanupSockets();
}
}

private function renderTask(Terminal $terminal, ?string $line = null): string
{
if ($line) {
$this->log[] = $line;
}

return $this->renderer->render(
terminal: $terminal,
state: $this->state,
startedAt: $this->startedAt,
finishedAt: $this->finishedAt,
hint: end($this->log) ?: null,
);
}

private function cleanupSockets(): void
{
foreach ($this->sockets as $socket) {
if (is_resource($socket)) {
@fclose($socket);
}
}

$this->sockets = [];
}

private function executeHandler(): void
{
$log = function (string ...$lines): void {
arr($lines)
->flatMap(fn (string $line) => explode("\n", $line))
->each(function (string $line): void {
fwrite($this->sockets[0], $line);
});
};

try {
exit((int) (($this->handler ?? static fn (): bool => true)($log) === false));
} catch (Throwable) {
exit(1);
}
}

private function resolveHandler(null|Process|Closure $handler): ?Closure
{
if ($handler === null) {
return null;
}

if ($handler instanceof Process) {
return static function (Closure $log) use ($handler): bool {
return $handler->run(function (string $output, string $buffer) use ($log): bool {
if ($output === Process::ERR) {
return true;
}

if ($line = trim($buffer)) {
$log($buffer);
}

return true;
}) === 0;
};
}

return $handler;
}

public function renderFooter(Terminal $terminal): ?string
{
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Tempest\Validation\Exceptions\InvalidValueException;
use Tempest\Validation\Rule;
use Tempest\Validation\Validator;
use function Tempest\Support\arr;

final class InteractiveComponentRenderer
{
Expand Down Expand Up @@ -153,6 +154,8 @@ private function applyKey(InteractiveConsoleComponent $component, Console $conso
continue;
}

Fiber::suspend();

// If valid, we can return
return $return;
}
Expand Down Expand Up @@ -231,6 +234,19 @@ private function validate(mixed $value, array $validation): ?Rule
return null;
}

public function isComponentSupported(Console $console, InteractiveConsoleComponent $component): bool
{
if (! arr($component->extensions ?? [])->every(fn (string $ext) => extension_loaded($ext))) {
return false;
}

if (! new Terminal($console)->supportsTty()) {
return false;
}

return true;
}

private function createTerminal(Console $console): Terminal
{
$terminal = new Terminal($console);
Expand Down
31 changes: 31 additions & 0 deletions src/Tempest/Console/src/Components/Renderers/SpinnerRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Tempest\Console\Components\Renderers;

use Tempest\Console\Components\ComponentState;
use Tempest\Console\Terminal\Terminal;

final class SpinnerRenderer
{
private const array FRAMES = ['⠁', '⠁', '⠉', '⠙', '⠚', '⠒', '⠂', '⠂', '⠒', '⠲', '⠴', '⠤', '⠄', '⠄', '⠤', '⠠', '⠠', '⠤', '⠦', '⠖', '⠒', '⠐', '⠐', '⠒', '⠓', '⠋', '⠉', '⠈', '⠈'];

private int $index = 0;

private(set) int $speed = 80_000;

public function render(Terminal $terminal, ComponentState $state): string
{
if ($state !== ComponentState::ACTIVE) {
return '';
}

$margin = str_repeat(' ', times: 2);
$previous = $this->index;

$this->index = ($this->index + 1) % count(self::FRAMES);

return self::FRAMES[$previous];
}
}
62 changes: 62 additions & 0 deletions src/Tempest/Console/src/Components/Renderers/TaskRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace Tempest\Console\Components\Renderers;

use Tempest\Console\Components\ComponentState;
use Tempest\Console\Terminal\Terminal;
use function Tempest\Support\str;

final class TaskRenderer
{
use RendersInput;

public function __construct(
private readonly SpinnerRenderer $spinner,
private readonly string $label,
) {
}

public function render(Terminal $terminal, ComponentState $state, float $startedAt, ?float $finishedAt, ?string $hint = null): string
{
$this->prepareRender($terminal, $state);
$this->label($this->label);

$runtime = fn (float $finishedAt) => $finishedAt
? number_format(($finishedAt - $startedAt) / 1_000_000, decimals: 0)
: null;

$hint = match ($this->state) {
ComponentState::ERROR => '<style="fg-red">An error occurred.</style>',
ComponentState::CANCELLED => '<style="fg-yellow">Cancelled.</style>',
ComponentState::SUBMITTED => $finishedAt
? '<style="fg-gray">Done in <style="bold">'.$runtime($finishedAt).'ms</style>.</style>'
: '<style="fg-gray">Done.</style>',
default => $hint ?? $runtime(hrtime(as_number: true)) . 'ms',
};

$this->line(
append: str()
->append(match ($this->state) {
ComponentState::SUBMITTED => '<style="fg-green">✔</style>',
ComponentState::ERROR => '<style="fg-red">✖</style>',
ComponentState::CANCELLED => '<style="fg-yellow">⚠</style>',
default => '<style="fg-gray">'.$this->spinner->render($terminal, $this->state).'</style>',
})
->append('<style="fg-gray"> ', $hint, '</style>'),
);

// If a task has an error, it is no longer active.
if (in_array($this->state, [ComponentState::ACTIVE, ComponentState::CANCELLED])) {
$this->newLine();
}

return $this->finishRender();
}

public function delay(): int
{
return $this->spinner->speed;
}
}
3 changes: 3 additions & 0 deletions src/Tempest/Console/src/Console.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use BackedEnum;
use Closure;
use Stringable;
use Symfony\Component\Process\Process;
use Tempest\Highlight\Language;
use Tempest\Support\ArrayHelper;

Expand Down Expand Up @@ -58,6 +59,8 @@ public function progressBar(iterable $data, Closure $handler): array;
*/
public function search(string $label, Closure $search, bool $multiple = false, null|string|array $default = null): mixed;

public function task(string $label, null|Process|Closure $handler): bool;

public function header(string $header, ?string $subheader = null): self;

public function info(string $line, ?string $symbol = null): self;
Expand Down
9 changes: 8 additions & 1 deletion src/Tempest/Console/src/GenericConsole.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
use BackedEnum;
use Closure;
use Stringable;
use Symfony\Component\Process\Process;
use Tempest\Console\Actions\ExecuteConsoleCommand;
use Tempest\Console\Components\Interactive\ConfirmComponent;
use Tempest\Console\Components\Interactive\MultipleChoiceComponent;
use Tempest\Console\Components\Interactive\PasswordComponent;
use Tempest\Console\Components\Interactive\ProgressBarComponent;
use Tempest\Console\Components\Interactive\SearchComponent;
use Tempest\Console\Components\Interactive\SingleChoiceComponent;
use Tempest\Console\Components\Interactive\TaskComponent;
use Tempest\Console\Components\Interactive\TextInputComponent;
use Tempest\Console\Components\InteractiveComponentRenderer;
use Tempest\Console\Exceptions\UnsupportedComponent;
Expand Down Expand Up @@ -183,7 +185,7 @@ public function withLabel(string $label): self

public function component(InteractiveConsoleComponent $component, array $validation = []): mixed
{
if ($this->componentRenderer !== null) {
if ($this->componentRenderer !== null && $this->componentRenderer->isComponentSupported($this, $component)) {
return $this->componentRenderer->render($this, $component, $validation);
}

Expand Down Expand Up @@ -274,6 +276,11 @@ public function progressBar(iterable $data, Closure $handler): array
return $this->component(new ProgressBarComponent($data, $handler));
}

public function task(string $label, null|Process|Closure $handler = null): bool
{
return $this->component(new TaskComponent($label, $handler));
}

public function search(string $label, Closure $search, bool $multiple = false, null|string|array $default = null): mixed
{
return $this->component(new SearchComponent($label, $search, $multiple, $default));
Expand Down
Loading
Loading