Skip to content

Commit d4dac18

Browse files
innocenzibrendt
andauthored
feat(console): add task component (#857)
Co-authored-by: Brent Roose <[email protected]>
1 parent ddfc153 commit d4dac18

File tree

11 files changed

+407
-2
lines changed

11 files changed

+407
-2
lines changed

composer.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@
7979
"tempest/view": "self.version",
8080
"tempest/vite": "self.version"
8181
},
82+
"suggest": {
83+
"ext-pcntl": "Required to use some interactive console components."
84+
},
8285
"minimum-stability": "dev",
8386
"prefer-stable": true,
8487
"autoload": {

src/Tempest/Console/src/Components/ComponentState.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,12 @@ enum ComponentState
3030
* Input is blocked.
3131
*/
3232
case BLOCKED;
33+
34+
public function isFinished(): bool
35+
{
36+
return match ($this) {
37+
self::ACTIVE, self::ERROR, self::BLOCKED => false,
38+
default => true,
39+
};
40+
}
3341
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Console\Components\Interactive;
6+
7+
use Closure;
8+
use Generator;
9+
use RuntimeException;
10+
use Symfony\Component\Process\Process;
11+
use Tempest\Console\Components\ComponentState;
12+
use Tempest\Console\Components\Concerns\HasErrors;
13+
use Tempest\Console\Components\Concerns\HasState;
14+
use Tempest\Console\Components\Renderers\SpinnerRenderer;
15+
use Tempest\Console\Components\Renderers\TaskRenderer;
16+
use Tempest\Console\InteractiveConsoleComponent;
17+
use Tempest\Console\Terminal\Terminal;
18+
use Throwable;
19+
use function Tempest\Support\arr;
20+
21+
final class TaskComponent implements InteractiveConsoleComponent
22+
{
23+
use HasErrors;
24+
use HasState;
25+
26+
private TaskRenderer $renderer;
27+
28+
private int $processId;
29+
30+
private float $startedAt;
31+
32+
private ?float $finishedAt = null;
33+
34+
private array $sockets;
35+
36+
private array $log = [];
37+
38+
private(set) array $extensions = ['pcntl'];
39+
40+
public function __construct(
41+
readonly string $label,
42+
private null|Process|Closure $handler = null,
43+
) {
44+
$this->handler = $this->resolveHandler($handler);
45+
$this->renderer = new TaskRenderer(new SpinnerRenderer(), $label);
46+
$this->startedAt = hrtime(as_number: true);
47+
}
48+
49+
public function render(Terminal $terminal): Generator
50+
{
51+
// If there is no task handler, we don't need to fork the process, as
52+
// it is a time-consuming operation. We can simply consider it done.
53+
if ($this->handler === null) {
54+
$this->state = ComponentState::SUBMITTED;
55+
56+
yield $this->renderTask($terminal);
57+
58+
return true;
59+
}
60+
61+
$this->sockets = stream_socket_pair(domain: STREAM_PF_UNIX, type: STREAM_SOCK_STREAM, protocol: STREAM_IPPROTO_IP);
62+
$this->processId = pcntl_fork();
63+
64+
if ($this->processId === -1) {
65+
throw new RuntimeException('Could not fork process');
66+
}
67+
68+
if (! $this->processId) {
69+
$this->executeHandler();
70+
}
71+
72+
try {
73+
fclose($this->sockets[0]);
74+
stream_set_blocking($this->sockets[1], enable: false);
75+
76+
while (true) {
77+
// The process is still running, so we continue looping.
78+
if (pcntl_waitpid($this->processId, $status, flags: WNOHANG) === 0) {
79+
yield $this->renderTask(
80+
terminal: $terminal,
81+
line: fread($this->sockets[1], length: 1024) ?: null,
82+
);
83+
84+
usleep($this->renderer->delay());
85+
86+
continue;
87+
}
88+
89+
// The process is done, we register the finishing timestamp,
90+
// close the communication socket and determine the finished state.
91+
fclose($this->sockets[1]);
92+
$this->finishedAt = hrtime(as_number: true);
93+
$this->state = match (pcntl_wifexited($status)) {
94+
true => match (pcntl_wexitstatus($status)) {
95+
0 => ComponentState::SUBMITTED,
96+
default => ComponentState::ERROR,
97+
},
98+
default => ComponentState::CANCELLED,
99+
};
100+
101+
yield $this->renderTask($terminal);
102+
103+
return $this->state === ComponentState::SUBMITTED;
104+
}
105+
} finally {
106+
if ($this->state->isFinished() && $this->processId) {
107+
posix_kill($this->processId, SIGTERM);
108+
}
109+
110+
$this->cleanupSockets();
111+
}
112+
}
113+
114+
private function renderTask(Terminal $terminal, ?string $line = null): string
115+
{
116+
if ($line) {
117+
$this->log[] = $line;
118+
}
119+
120+
return $this->renderer->render(
121+
terminal: $terminal,
122+
state: $this->state,
123+
startedAt: $this->startedAt,
124+
finishedAt: $this->finishedAt,
125+
hint: end($this->log) ?: null,
126+
);
127+
}
128+
129+
private function cleanupSockets(): void
130+
{
131+
foreach ($this->sockets as $socket) {
132+
if (is_resource($socket)) {
133+
@fclose($socket);
134+
}
135+
}
136+
137+
$this->sockets = [];
138+
}
139+
140+
private function executeHandler(): void
141+
{
142+
$log = function (string ...$lines): void {
143+
arr($lines)
144+
->flatMap(fn (string $line) => explode("\n", $line))
145+
->each(function (string $line): void {
146+
fwrite($this->sockets[0], $line);
147+
});
148+
};
149+
150+
try {
151+
exit((int) (($this->handler ?? static fn (): bool => true)($log) === false));
152+
} catch (Throwable) {
153+
exit(1);
154+
}
155+
}
156+
157+
private function resolveHandler(null|Process|Closure $handler): ?Closure
158+
{
159+
if ($handler === null) {
160+
return null;
161+
}
162+
163+
if ($handler instanceof Process) {
164+
return static function (Closure $log) use ($handler): bool {
165+
return $handler->run(function (string $output, string $buffer) use ($log): bool {
166+
if ($output === Process::ERR) {
167+
return true;
168+
}
169+
170+
if ($line = trim($buffer)) {
171+
$log($buffer);
172+
}
173+
174+
return true;
175+
}) === 0;
176+
};
177+
}
178+
179+
return $handler;
180+
}
181+
182+
public function renderFooter(Terminal $terminal): ?string
183+
{
184+
return null;
185+
}
186+
}

src/Tempest/Console/src/Components/InteractiveComponentRenderer.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Tempest\Validation\Exceptions\InvalidValueException;
1717
use Tempest\Validation\Rule;
1818
use Tempest\Validation\Validator;
19+
use function Tempest\Support\arr;
1920

2021
final class InteractiveComponentRenderer
2122
{
@@ -153,6 +154,8 @@ private function applyKey(InteractiveConsoleComponent $component, Console $conso
153154
continue;
154155
}
155156

157+
Fiber::suspend();
158+
156159
// If valid, we can return
157160
return $return;
158161
}
@@ -231,9 +234,23 @@ private function validate(mixed $value, array $validation): ?Rule
231234
return null;
232235
}
233236

237+
public function isComponentSupported(Console $console, InteractiveConsoleComponent $component): bool
238+
{
239+
if (! arr($component->extensions ?? [])->every(fn (string $ext) => extension_loaded($ext))) {
240+
return false;
241+
}
242+
243+
if (! new Terminal($console)->supportsTty()) {
244+
return false;
245+
}
246+
247+
return true;
248+
}
249+
234250
private function createTerminal(Console $console): Terminal
235251
{
236252
$terminal = new Terminal($console);
253+
$terminal->switchToInteractiveMode();
237254
$terminal->cursor->clearAfter();
238255
stream_set_blocking(STDIN, false);
239256

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Console\Components\Renderers;
6+
7+
use Tempest\Console\Components\ComponentState;
8+
use Tempest\Console\Terminal\Terminal;
9+
10+
final class SpinnerRenderer
11+
{
12+
private const array FRAMES = ['', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', ''];
13+
14+
private int $index = 0;
15+
16+
private(set) int $speed = 80_000;
17+
18+
public function render(Terminal $terminal, ComponentState $state): string
19+
{
20+
if ($state !== ComponentState::ACTIVE) {
21+
return '';
22+
}
23+
24+
$margin = str_repeat(' ', times: 2);
25+
$previous = $this->index;
26+
27+
$this->index = ($this->index + 1) % count(self::FRAMES);
28+
29+
return self::FRAMES[$previous];
30+
}
31+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Console\Components\Renderers;
6+
7+
use Tempest\Console\Components\ComponentState;
8+
use Tempest\Console\Terminal\Terminal;
9+
use function Tempest\Support\str;
10+
11+
final class TaskRenderer
12+
{
13+
use RendersInput;
14+
15+
public function __construct(
16+
private readonly SpinnerRenderer $spinner,
17+
private readonly string $label,
18+
) {
19+
}
20+
21+
public function render(Terminal $terminal, ComponentState $state, float $startedAt, ?float $finishedAt, ?string $hint = null): string
22+
{
23+
$this->prepareRender($terminal, $state);
24+
$this->label($this->label);
25+
26+
$runtime = fn (float $finishedAt) => $finishedAt
27+
? number_format(($finishedAt - $startedAt) / 1_000_000, decimals: 0)
28+
: null;
29+
30+
$hint = match ($this->state) {
31+
ComponentState::ERROR => '<style="fg-red">An error occurred.</style>',
32+
ComponentState::CANCELLED => '<style="fg-yellow">Cancelled.</style>',
33+
ComponentState::SUBMITTED => $finishedAt
34+
? '<style="fg-gray">Done in <style="bold">'.$runtime($finishedAt).'ms</style>.</style>'
35+
: '<style="fg-gray">Done.</style>',
36+
default => $hint ?? $runtime(hrtime(as_number: true)) . 'ms',
37+
};
38+
39+
$this->line(
40+
append: str()
41+
->append(match ($this->state) {
42+
ComponentState::SUBMITTED => '<style="fg-green">✔</style>',
43+
ComponentState::ERROR => '<style="fg-red">✖</style>',
44+
ComponentState::CANCELLED => '<style="fg-yellow">⚠</style>',
45+
default => '<style="fg-gray">'.$this->spinner->render($terminal, $this->state).'</style>',
46+
})
47+
->append('<style="fg-gray"> ', $hint, '</style>'),
48+
);
49+
50+
// If a task has an error, it is no longer active.
51+
if (in_array($this->state, [ComponentState::ACTIVE, ComponentState::CANCELLED])) {
52+
$this->newLine();
53+
}
54+
55+
return $this->finishRender();
56+
}
57+
58+
public function delay(): int
59+
{
60+
return $this->spinner->speed;
61+
}
62+
}

src/Tempest/Console/src/Console.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use BackedEnum;
88
use Closure;
99
use Stringable;
10+
use Symfony\Component\Process\Process;
1011
use Tempest\Highlight\Language;
1112
use Tempest\Support\ArrayHelper;
1213

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

62+
public function task(string $label, null|Process|Closure $handler): bool;
63+
6164
public function header(string $header, ?string $subheader = null): self;
6265

6366
public function info(string $line, ?string $symbol = null): self;

src/Tempest/Console/src/GenericConsole.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
use BackedEnum;
88
use Closure;
99
use Stringable;
10+
use Symfony\Component\Process\Process;
1011
use Tempest\Console\Actions\ExecuteConsoleCommand;
1112
use Tempest\Console\Components\Interactive\ConfirmComponent;
1213
use Tempest\Console\Components\Interactive\MultipleChoiceComponent;
1314
use Tempest\Console\Components\Interactive\PasswordComponent;
1415
use Tempest\Console\Components\Interactive\ProgressBarComponent;
1516
use Tempest\Console\Components\Interactive\SearchComponent;
1617
use Tempest\Console\Components\Interactive\SingleChoiceComponent;
18+
use Tempest\Console\Components\Interactive\TaskComponent;
1719
use Tempest\Console\Components\Interactive\TextInputComponent;
1820
use Tempest\Console\Components\InteractiveComponentRenderer;
1921
use Tempest\Console\Exceptions\UnsupportedComponent;
@@ -183,7 +185,7 @@ public function withLabel(string $label): self
183185

184186
public function component(InteractiveConsoleComponent $component, array $validation = []): mixed
185187
{
186-
if ($this->componentRenderer !== null) {
188+
if ($this->componentRenderer !== null && $this->componentRenderer->isComponentSupported($this, $component)) {
187189
return $this->componentRenderer->render($this, $component, $validation);
188190
}
189191

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

279+
public function task(string $label, null|Process|Closure $handler = null): bool
280+
{
281+
return $this->component(new TaskComponent($label, $handler));
282+
}
283+
277284
public function search(string $label, Closure $search, bool $multiple = false, null|string|array $default = null): mixed
278285
{
279286
return $this->component(new SearchComponent($label, $search, $multiple, $default));

0 commit comments

Comments
 (0)