mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Update to latest tmux pane handling
This commit is contained in:
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\TmuxPaneRole;
|
||||
use App\Models\Settings;
|
||||
use App\Services\Tmux\TmuxPaneManager;
|
||||
use App\Services\Tmux\TmuxSessionManager;
|
||||
use Illuminate\Console\Command;
|
||||
use RuntimeException;
|
||||
|
||||
class TmuxAttach extends Command
|
||||
{
|
||||
@@ -49,12 +51,20 @@ class TmuxAttach extends Command
|
||||
|
||||
// Select monitor pane before attaching so user lands there
|
||||
$paneManager = new TmuxPaneManager($sessionName);
|
||||
$paneManager->selectWindow(0);
|
||||
$paneManager->selectPane('0.0');
|
||||
try {
|
||||
$monitorPane = $paneManager->paneForRole(TmuxPaneRole::Monitor, '0.0');
|
||||
} catch (RuntimeException $exception) {
|
||||
$this->error('❌ '.$exception->getMessage());
|
||||
|
||||
// Execute tmux attach
|
||||
passthru("tmux attach -t {$sessionName}");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
if (! $paneManager->selectWindow(0) || ! $paneManager->selectPane($monitorPane)) {
|
||||
$this->error('❌ Unable to select the tmux monitor pane.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
return $sessionManager->attachSession() ? Command::SUCCESS : Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\TmuxPaneRole;
|
||||
use App\Models\Settings;
|
||||
use App\Services\Tmux\TmuxPaneManager;
|
||||
use App\Services\Tmux\TmuxSessionManager;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -77,7 +79,7 @@ class TmuxHealthCheck extends Command
|
||||
|
||||
if ($monitorPaneDead) {
|
||||
if (! $quiet) {
|
||||
$this->warn('⚠️ Monitor pane (0.0) is dead.');
|
||||
$this->warn('⚠️ Monitor pane is dead.');
|
||||
}
|
||||
|
||||
if ($autoRestart) {
|
||||
@@ -92,7 +94,7 @@ class TmuxHealthCheck extends Command
|
||||
}
|
||||
|
||||
if (! $quiet) {
|
||||
$this->info('✅ Monitor pane (0.0) is alive and running.');
|
||||
$this->info('✅ Monitor pane is alive and running.');
|
||||
}
|
||||
|
||||
$this->logHealthCheck('info', 'healthy');
|
||||
@@ -111,12 +113,20 @@ class TmuxHealthCheck extends Command
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the monitor pane (0.0) is dead.
|
||||
* Check if the monitor pane is dead.
|
||||
*/
|
||||
private function isMonitorPaneDead(): bool
|
||||
{
|
||||
$paneManager = new TmuxPaneManager($this->sessionName);
|
||||
|
||||
try {
|
||||
$monitorPane = $paneManager->paneForRole(TmuxPaneRole::Monitor, '0.0');
|
||||
} catch (\RuntimeException) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$result = Process::timeout(10)->run(
|
||||
"tmux display-message -p -t {$this->sessionName}:0.0 '#{pane_dead}'"
|
||||
['tmux', 'display-message', '-p', '-t', $monitorPane, '#{pane_dead}']
|
||||
);
|
||||
|
||||
if (! $result->successful()) {
|
||||
@@ -130,7 +140,7 @@ class TmuxHealthCheck extends Command
|
||||
}
|
||||
|
||||
$commandResult = Process::timeout(10)->run(
|
||||
"tmux display-message -p -t {$this->sessionName}:0.0 '#{pane_current_command}'"
|
||||
['tmux', 'display-message', '-p', '-t', $monitorPane, '#{pane_current_command}']
|
||||
);
|
||||
|
||||
if (! $commandResult->successful()) {
|
||||
|
||||
@@ -193,7 +193,7 @@ class TmuxMonitor extends Command
|
||||
$this->taskRunner->runBackfill($runVar);
|
||||
|
||||
// Update releases
|
||||
$this->taskRunner->runReleasesUpdate(array_merge($runVar, ['pane' => '0.3']));
|
||||
$this->taskRunner->runReleasesUpdate($runVar);
|
||||
|
||||
// Post-processing and cleanup tasks
|
||||
$this->runPostProcessingTasks($runVar);
|
||||
@@ -207,7 +207,7 @@ class TmuxMonitor extends Command
|
||||
private function runBasicTasks(array $runVar): void
|
||||
{
|
||||
// Update releases
|
||||
$this->taskRunner->runReleasesUpdate(array_merge($runVar, ['pane' => '0.1']));
|
||||
$this->taskRunner->runReleasesUpdate($runVar);
|
||||
|
||||
// Post-processing and cleanup tasks
|
||||
$this->runPostProcessingTasks($runVar);
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\TmuxPaneRole;
|
||||
use App\Models\Collection;
|
||||
use App\Models\Settings;
|
||||
use App\Services\Tmux\TmuxLayoutBuilder;
|
||||
@@ -36,6 +37,8 @@ class TmuxStart extends Command
|
||||
|
||||
private TmuxLayoutBuilder $layoutBuilder;
|
||||
|
||||
private bool $sessionCreated = false;
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
@@ -86,12 +89,13 @@ class TmuxStart extends Command
|
||||
|
||||
// Build the tmux layout
|
||||
if (! $this->layoutBuilder->buildLayout($sequential)) {
|
||||
$this->error('❌ Failed to build tmux layout');
|
||||
$this->error('❌ Failed to build tmux layout: '.($this->layoutBuilder->lastError() ?? 'unknown error'));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('✅ Tmux layout created');
|
||||
$this->sessionCreated = true;
|
||||
|
||||
// Set running flag
|
||||
Settings::query()->where('name', 'running')->update(['value' => 1]);
|
||||
@@ -101,17 +105,23 @@ class TmuxStart extends Command
|
||||
$this->info('🚀 Starting monitor...');
|
||||
$this->startMonitor($sessionName);
|
||||
|
||||
// Select monitor pane (0.0) so attach lands there
|
||||
// Select monitor pane so attach lands there
|
||||
$paneManager = new TmuxPaneManager($sessionName);
|
||||
$paneManager->selectWindow(0);
|
||||
$paneManager->selectPane('0.0');
|
||||
if (! $paneManager->selectWindow(0)
|
||||
|| ! $paneManager->selectPane($paneManager->paneForRole(TmuxPaneRole::Monitor, '0.0'))) {
|
||||
throw new \RuntimeException($paneManager->lastError() ?? 'Unable to select the tmux monitor pane.');
|
||||
}
|
||||
|
||||
$this->info("✅ Tmux session '{$sessionName}' started successfully");
|
||||
|
||||
// Attach if requested
|
||||
if ($this->option('attach')) {
|
||||
$this->info('📎 Attaching to session...');
|
||||
$this->sessionManager->attachSession();
|
||||
if (! $this->sessionManager->attachSession()) {
|
||||
$this->error('❌ Unable to attach to the tmux session.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
} else {
|
||||
$this->info("💡 To attach to the session, run: tmux attach -t {$sessionName}");
|
||||
$this->info('💡 Or use: php artisan tmux:attach');
|
||||
@@ -120,6 +130,11 @@ class TmuxStart extends Command
|
||||
return Command::SUCCESS;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
if ($this->sessionCreated && isset($this->sessionManager)) {
|
||||
$this->sessionManager->killSession();
|
||||
Settings::query()->where('name', 'running')->update(['value' => 0]);
|
||||
}
|
||||
|
||||
$this->error('❌ Failed to start tmux: '.$e->getMessage());
|
||||
logger()->error('Tmux start error', [
|
||||
'message' => $e->getMessage(),
|
||||
@@ -192,7 +207,9 @@ class TmuxStart extends Command
|
||||
$this->warn(' ⚠ Using artisan command (not recommended for pane)');
|
||||
}
|
||||
|
||||
// Spawn monitor in pane 0.0
|
||||
$paneManager->respawnPane('0.0', $command);
|
||||
$monitorPane = $paneManager->paneForRole(TmuxPaneRole::Monitor, '0.0');
|
||||
if (! $paneManager->respawnPane($monitorPane, $command)) {
|
||||
throw new \RuntimeException('Unable to start the tmux monitor pane.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum TmuxPaneRole: string
|
||||
{
|
||||
case Monitor = 'monitor';
|
||||
case Binaries = 'binaries';
|
||||
case Backfill = 'backfill';
|
||||
case Releases = 'releases';
|
||||
case Sequential = 'sequential';
|
||||
case FixNames = 'fix_names';
|
||||
case RemoveCrap = 'remove_crap';
|
||||
case PostAdditional = 'post_additional';
|
||||
case PostTv = 'post_tv';
|
||||
case PostMetadata = 'post_metadata';
|
||||
case PostMovies = 'post_movies';
|
||||
case IrcScraper = 'irc_scraper';
|
||||
case Htop = 'htop';
|
||||
case Nmon = 'nmon';
|
||||
case Vnstat = 'vnstat';
|
||||
case Tcptrack = 'tcptrack';
|
||||
case BandwidthMonitor = 'bandwidth_monitor';
|
||||
case Mytop = 'mytop';
|
||||
case Redis = 'redis';
|
||||
case Console = 'console';
|
||||
}
|
||||
@@ -4,8 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Tmux;
|
||||
|
||||
use App\Enums\TmuxPaneRole;
|
||||
use App\Models\Settings;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Service for building tmux window layouts based on sequential mode
|
||||
@@ -20,6 +23,10 @@ class TmuxLayoutBuilder
|
||||
|
||||
protected string $sessionName;
|
||||
|
||||
private ?string $lastError = null;
|
||||
|
||||
private bool $sessionCreated = false;
|
||||
|
||||
/**
|
||||
* Pane name icons mapping - uses Nerd Font symbols
|
||||
* Requires a Nerd Font to display properly (FiraCode NF, JetBrains Mono NF, etc.)
|
||||
@@ -61,7 +68,7 @@ class TmuxLayoutBuilder
|
||||
public function __construct(TmuxSessionManager $sessionManager)
|
||||
{
|
||||
$this->sessionManager = $sessionManager;
|
||||
$this->sessionName = $sessionManager->getSessionName();
|
||||
$this->sessionName = $sessionManager->sessionName();
|
||||
$this->paneManager = new TmuxPaneManager($this->sessionName);
|
||||
}
|
||||
|
||||
@@ -78,55 +85,90 @@ class TmuxLayoutBuilder
|
||||
*/
|
||||
public function buildLayout(int $sequentialMode): bool
|
||||
{
|
||||
return match ($sequentialMode) {
|
||||
1 => $this->buildBasicLayout(),
|
||||
2 => $this->buildStrippedLayout(),
|
||||
default => $this->buildFullLayout(),
|
||||
};
|
||||
try {
|
||||
match ($sequentialMode) {
|
||||
1 => $this->buildBasicLayout(),
|
||||
2 => $this->buildStrippedLayout(),
|
||||
default => $this->buildFullLayout(),
|
||||
};
|
||||
|
||||
return true;
|
||||
} catch (Throwable $exception) {
|
||||
$this->lastError = $exception->getMessage();
|
||||
|
||||
if ($this->sessionCreated) {
|
||||
$this->sessionManager->killSession();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build full non-sequential layout (mode 0)
|
||||
*/
|
||||
protected function buildFullLayout(): bool
|
||||
protected function buildFullLayout(): void
|
||||
{
|
||||
// Window 0: Monitor + Binaries + Backfill + Releases
|
||||
if (! $this->sessionManager->createSession($this->getPaneDisplayName('Monitor'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Select pane 0.0, then split for binaries (right side, 67%)
|
||||
$this->paneManager->selectPane('0.0');
|
||||
$this->paneManager->splitHorizontal('0', '67%', $this->getPaneDisplayName('update_binaries'));
|
||||
|
||||
// Select pane 0.1 (binaries), then split for backfill (bottom, 67%)
|
||||
$this->paneManager->selectPane('0.1');
|
||||
$this->paneManager->splitVertical('0', '67%', $this->getPaneDisplayName('backfill'));
|
||||
|
||||
// Split again for releases (bottom, 50%)
|
||||
$this->paneManager->splitVertical('0', '50%', $this->getPaneDisplayName('update_releases'));
|
||||
$monitor = $this->createSessionPane(TmuxPaneRole::Monitor, $this->getPaneDisplayName('Monitor'));
|
||||
$binaries = $this->splitHorizontal(
|
||||
$monitor,
|
||||
67,
|
||||
TmuxPaneRole::Binaries,
|
||||
$this->getPaneDisplayName('update_binaries'),
|
||||
);
|
||||
$backfill = $this->splitVertical(
|
||||
$binaries,
|
||||
67,
|
||||
TmuxPaneRole::Backfill,
|
||||
$this->getPaneDisplayName('backfill'),
|
||||
);
|
||||
$this->splitVertical(
|
||||
$backfill,
|
||||
50,
|
||||
TmuxPaneRole::Releases,
|
||||
$this->getPaneDisplayName('update_releases'),
|
||||
);
|
||||
|
||||
// Window 1: Fix names + Remove crap
|
||||
$this->paneManager->createWindow(1, ' Utils');
|
||||
$this->paneManager->setPaneTitle('1.0', $this->getPaneDisplayName('fixReleaseNames'));
|
||||
$this->paneManager->selectPane('1.0');
|
||||
$this->paneManager->splitHorizontal('1', '50%', $this->getPaneDisplayName('removeCrapReleases'));
|
||||
$fixNames = $this->createWindowPane(
|
||||
1,
|
||||
' Utils',
|
||||
TmuxPaneRole::FixNames,
|
||||
$this->getPaneDisplayName('fixReleaseNames'),
|
||||
);
|
||||
$this->splitHorizontal(
|
||||
$fixNames,
|
||||
50,
|
||||
TmuxPaneRole::RemoveCrap,
|
||||
$this->getPaneDisplayName('removeCrapReleases'),
|
||||
);
|
||||
|
||||
// Window 2: Postprocessing (Left: Additional + TV + Metadata, Right: Movies + XXX)
|
||||
$this->paneManager->createWindow(2, ' Post');
|
||||
$this->paneManager->setPaneTitle('2.0', $this->getPaneDisplayName('postprocessing_additional'));
|
||||
|
||||
// Split horizontally to create left and right halves (don't set title yet)
|
||||
$this->paneManager->splitHorizontal('2', '50%', '');
|
||||
|
||||
// Left side (2.0): split vertically for TV and Metadata
|
||||
$this->paneManager->selectPane('2.0');
|
||||
$this->paneManager->splitVertical('2', '67%', $this->getPaneDisplayName('postprocessing_tv'));
|
||||
$this->paneManager->splitVertical('2', '50%', $this->getPaneDisplayName('postprocessing_amazon'));
|
||||
|
||||
// Right side: After left splits, right side is now 2.3
|
||||
$this->paneManager->selectPane('2.3');
|
||||
$this->paneManager->setPaneTitle('2.3', $this->getPaneDisplayName('postprocessing_movies'));
|
||||
// Window 2: Postprocessing (left stack and right-side movies)
|
||||
$additional = $this->createWindowPane(
|
||||
2,
|
||||
' Post',
|
||||
TmuxPaneRole::PostAdditional,
|
||||
$this->getPaneDisplayName('postprocessing_additional'),
|
||||
);
|
||||
$this->splitHorizontal(
|
||||
$additional,
|
||||
50,
|
||||
TmuxPaneRole::PostMovies,
|
||||
$this->getPaneDisplayName('postprocessing_movies'),
|
||||
);
|
||||
$tv = $this->splitVertical(
|
||||
$additional,
|
||||
67,
|
||||
TmuxPaneRole::PostTv,
|
||||
$this->getPaneDisplayName('postprocessing_tv'),
|
||||
);
|
||||
$this->splitVertical(
|
||||
$tv,
|
||||
50,
|
||||
TmuxPaneRole::PostMetadata,
|
||||
$this->getPaneDisplayName('postprocessing_amazon'),
|
||||
);
|
||||
|
||||
// Window 3: IRC Scraper
|
||||
$this->createIRCScraperWindow();
|
||||
@@ -134,90 +176,115 @@ class TmuxLayoutBuilder
|
||||
// Additional windows (optional monitoring tools)
|
||||
$this->createOptionalWindows();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build basic sequential layout (mode 1)
|
||||
*/
|
||||
protected function buildBasicLayout(): bool
|
||||
protected function buildBasicLayout(): void
|
||||
{
|
||||
// Window 0: Monitor + Releases
|
||||
if (! $this->sessionManager->createSession($this->getPaneDisplayName('Monitor'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Select pane 0.0, then split for releases
|
||||
$this->paneManager->selectPane('0.0');
|
||||
$this->paneManager->splitHorizontal('0', '67%', $this->getPaneDisplayName('update_releases'));
|
||||
$monitor = $this->createSessionPane(TmuxPaneRole::Monitor, $this->getPaneDisplayName('Monitor'));
|
||||
$this->splitHorizontal(
|
||||
$monitor,
|
||||
67,
|
||||
TmuxPaneRole::Releases,
|
||||
$this->getPaneDisplayName('update_releases'),
|
||||
);
|
||||
|
||||
// Window 1: Utils (Fix names + Remove crap)
|
||||
$this->paneManager->createWindow(1, ' Utils');
|
||||
$this->paneManager->setPaneTitle('1.0', $this->getPaneDisplayName('fixReleaseNames'));
|
||||
$this->paneManager->selectPane('1.0');
|
||||
$this->paneManager->splitHorizontal('1', '50%', $this->getPaneDisplayName('removeCrapReleases'));
|
||||
$fixNames = $this->createWindowPane(
|
||||
1,
|
||||
' Utils',
|
||||
TmuxPaneRole::FixNames,
|
||||
$this->getPaneDisplayName('fixReleaseNames'),
|
||||
);
|
||||
$this->splitHorizontal(
|
||||
$fixNames,
|
||||
50,
|
||||
TmuxPaneRole::RemoveCrap,
|
||||
$this->getPaneDisplayName('removeCrapReleases'),
|
||||
);
|
||||
|
||||
// Window 2: Postprocessing (Left: Additional + TV + Metadata, Right: Movies + XXX)
|
||||
$this->paneManager->createWindow(2, ' Post');
|
||||
$this->paneManager->setPaneTitle('2.0', $this->getPaneDisplayName('postprocessing_additional'));
|
||||
|
||||
// Split horizontally to create left and right halves (don't set title yet)
|
||||
$this->paneManager->splitHorizontal('2', '50%', '');
|
||||
|
||||
// Left side (2.0): split vertically for TV and Metadata
|
||||
$this->paneManager->selectPane('2.0');
|
||||
$this->paneManager->splitVertical('2', '67%', $this->getPaneDisplayName('postprocessing_tv'));
|
||||
$this->paneManager->splitVertical('2', '50%', $this->getPaneDisplayName('postprocessing_amazon'));
|
||||
|
||||
// Right side: After left splits, right side is now 2.3
|
||||
$this->paneManager->selectPane('2.3');
|
||||
$this->paneManager->setPaneTitle('2.3', $this->getPaneDisplayName('postprocessing_movies'));
|
||||
// Window 2: Postprocessing
|
||||
$additional = $this->createWindowPane(
|
||||
2,
|
||||
' Post',
|
||||
TmuxPaneRole::PostAdditional,
|
||||
$this->getPaneDisplayName('postprocessing_additional'),
|
||||
);
|
||||
$this->splitHorizontal(
|
||||
$additional,
|
||||
50,
|
||||
TmuxPaneRole::PostMovies,
|
||||
$this->getPaneDisplayName('postprocessing_movies'),
|
||||
);
|
||||
$tv = $this->splitVertical(
|
||||
$additional,
|
||||
67,
|
||||
TmuxPaneRole::PostTv,
|
||||
$this->getPaneDisplayName('postprocessing_tv'),
|
||||
);
|
||||
$this->splitVertical(
|
||||
$tv,
|
||||
50,
|
||||
TmuxPaneRole::PostMetadata,
|
||||
$this->getPaneDisplayName('postprocessing_amazon'),
|
||||
);
|
||||
|
||||
// Window 3: IRC Scraper
|
||||
$this->createIRCScraperWindow();
|
||||
|
||||
$this->createOptionalWindows();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build stripped sequential layout (mode 2)
|
||||
*/
|
||||
protected function buildStrippedLayout(): bool
|
||||
protected function buildStrippedLayout(): void
|
||||
{
|
||||
// Window 0: Monitor + Sequential
|
||||
if (! $this->sessionManager->createSession($this->getPaneDisplayName('Monitor'))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Select pane 0.0, then split for sequential
|
||||
$this->paneManager->selectPane('0.0');
|
||||
$this->paneManager->splitHorizontal('0', '67%', $this->getPaneDisplayName('sequential'));
|
||||
$monitor = $this->createSessionPane(TmuxPaneRole::Monitor, $this->getPaneDisplayName('Monitor'));
|
||||
$this->splitHorizontal(
|
||||
$monitor,
|
||||
67,
|
||||
TmuxPaneRole::Sequential,
|
||||
$this->getPaneDisplayName('sequential'),
|
||||
);
|
||||
|
||||
// Window 1: Metadata postprocessing
|
||||
$this->paneManager->createWindow(1, ' Utils');
|
||||
$this->paneManager->setPaneTitle('1.0', $this->getPaneDisplayName('fixReleaseNames'));
|
||||
$this->paneManager->selectPane('1.0');
|
||||
$this->paneManager->splitHorizontal('1', '50%', $this->getPaneDisplayName('postprocessing_amazon'));
|
||||
$fixNames = $this->createWindowPane(
|
||||
1,
|
||||
' Utils',
|
||||
TmuxPaneRole::FixNames,
|
||||
$this->getPaneDisplayName('fixReleaseNames'),
|
||||
);
|
||||
$this->splitHorizontal(
|
||||
$fixNames,
|
||||
50,
|
||||
TmuxPaneRole::PostMetadata,
|
||||
$this->getPaneDisplayName('postprocessing_amazon'),
|
||||
);
|
||||
|
||||
// Window 2: IRC Scraper
|
||||
$this->createIRCScraperWindow();
|
||||
|
||||
$this->createOptionalWindows();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create IRC scraper window
|
||||
*/
|
||||
protected function createIRCScraperWindow(): bool
|
||||
protected function createIRCScraperWindow(): void
|
||||
{
|
||||
$this->paneManager->createWindow(3, ' IRC');
|
||||
$this->paneManager->setPaneTitle('3.0', $this->getPaneDisplayName('scrapeIRC'));
|
||||
|
||||
return true;
|
||||
$this->createWindowPane(
|
||||
3,
|
||||
' IRC',
|
||||
TmuxPaneRole::IrcScraper,
|
||||
$this->getPaneDisplayName('scrapeIRC'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,45 +306,45 @@ class TmuxLayoutBuilder
|
||||
|
||||
// htop
|
||||
if ((int) Settings::settingValue('htop') === 1 && $this->commandExists('htop')) {
|
||||
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('htop'));
|
||||
$this->paneManager->respawnPane("{$windowIndex}.0", 'htop');
|
||||
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('htop'), TmuxPaneRole::Htop, $this->getPaneDisplayName('htop'));
|
||||
$this->requireSuccess($this->paneManager->respawnPane($pane, 'htop'), 'respawn htop pane');
|
||||
$windowIndex++;
|
||||
}
|
||||
|
||||
// nmon
|
||||
if ((int) Settings::settingValue('nmon') === 1 && $this->commandExists('nmon')) {
|
||||
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('nmon'));
|
||||
$this->paneManager->respawnPane("{$windowIndex}.0", 'nmon -t');
|
||||
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('nmon'), TmuxPaneRole::Nmon, $this->getPaneDisplayName('nmon'));
|
||||
$this->requireSuccess($this->paneManager->respawnPane($pane, 'nmon -t'), 'respawn nmon pane');
|
||||
$windowIndex++;
|
||||
}
|
||||
|
||||
// vnstat
|
||||
if ((int) Settings::settingValue('vnstat') === 1 && $this->commandExists('vnstat')) {
|
||||
$vnstatArgs = Settings::settingValue('vnstat_args') ?? '';
|
||||
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('vnstat'));
|
||||
$this->paneManager->respawnPane("{$windowIndex}.0", "watch -n10 'vnstat {$vnstatArgs}'");
|
||||
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('vnstat'), TmuxPaneRole::Vnstat, $this->getPaneDisplayName('vnstat'));
|
||||
$this->requireSuccess($this->paneManager->respawnPane($pane, "watch -n10 'vnstat {$vnstatArgs}'"), 'respawn vnstat pane');
|
||||
$windowIndex++;
|
||||
}
|
||||
|
||||
// tcptrack
|
||||
if ((int) Settings::settingValue('tcptrack') === 1 && $this->commandExists('tcptrack')) {
|
||||
$tcptrackArgs = Settings::settingValue('tcptrack_args') ?? '';
|
||||
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('tcptrack'));
|
||||
$this->paneManager->respawnPane("{$windowIndex}.0", "tcptrack {$tcptrackArgs}");
|
||||
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('tcptrack'), TmuxPaneRole::Tcptrack, $this->getPaneDisplayName('tcptrack'));
|
||||
$this->requireSuccess($this->paneManager->respawnPane($pane, "tcptrack {$tcptrackArgs}"), 'respawn tcptrack pane');
|
||||
$windowIndex++;
|
||||
}
|
||||
|
||||
// bwm-ng
|
||||
if ((int) Settings::settingValue('bwmng') === 1 && $this->commandExists('bwm-ng')) {
|
||||
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('bwm-ng'));
|
||||
$this->paneManager->respawnPane("{$windowIndex}.0", 'bwm-ng');
|
||||
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('bwm-ng'), TmuxPaneRole::BandwidthMonitor, $this->getPaneDisplayName('bwm-ng'));
|
||||
$this->requireSuccess($this->paneManager->respawnPane($pane, 'bwm-ng'), 'respawn bwm-ng pane');
|
||||
$windowIndex++;
|
||||
}
|
||||
|
||||
// mytop
|
||||
if ((int) Settings::settingValue('mytop') === 1 && $this->commandExists('mytop')) {
|
||||
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('mytop'));
|
||||
$this->paneManager->respawnPane("{$windowIndex}.0", 'mytop -u');
|
||||
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('mytop'), TmuxPaneRole::Mytop, $this->getPaneDisplayName('mytop'));
|
||||
$this->requireSuccess($this->paneManager->respawnPane($pane, 'mytop -u'), 'respawn mytop pane');
|
||||
$windowIndex++;
|
||||
}
|
||||
|
||||
@@ -286,13 +353,13 @@ class TmuxLayoutBuilder
|
||||
$redisArgs = Settings::settingValue('redis_args') ?? '';
|
||||
$refreshInterval = 30;
|
||||
|
||||
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('redis'));
|
||||
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('redis'), TmuxPaneRole::Redis, $this->getPaneDisplayName('redis'));
|
||||
|
||||
// Check if custom args provided for simple redis-cli output
|
||||
if (! empty($redisArgs) && $redisArgs !== 'NULL' && $this->commandExists('redis-cli')) {
|
||||
$redisHost = config('database.redis.default.host', '127.0.0.1');
|
||||
$redisPort = config('database.redis.default.port', 6379);
|
||||
$this->paneManager->respawnPane("{$windowIndex}.0", "watch -n{$refreshInterval} -c 'redis-cli -h {$redisHost} -p {$redisPort} {$redisArgs}'");
|
||||
$this->requireSuccess($this->paneManager->respawnPane($pane, "watch -n{$refreshInterval} -c 'redis-cli -h {$redisHost} -p {$redisPort} {$redisArgs}'"), 'respawn Redis pane');
|
||||
} else {
|
||||
// Use PHP-based Redis monitor service
|
||||
$redisHost = config('database.redis.default.host', '127.0.0.1');
|
||||
@@ -305,11 +372,11 @@ class TmuxLayoutBuilder
|
||||
if ($connectionInfo['use_sail']) {
|
||||
// Use sail to run inside Docker container
|
||||
$sail = base_path('sail');
|
||||
$this->paneManager->respawnPane("{$windowIndex}.0", "{$sail} artisan redis:monitor --refresh={$refreshInterval}");
|
||||
$this->requireSuccess($this->paneManager->respawnPane($pane, "{$sail} artisan redis:monitor --refresh={$refreshInterval}"), 'respawn Redis pane');
|
||||
} else {
|
||||
// Run directly, potentially with host override
|
||||
$envPrefix = $connectionInfo['override_host'] ? "REDIS_HOST={$connectionInfo['host']} " : '';
|
||||
$this->paneManager->respawnPane("{$windowIndex}.0", "{$envPrefix}php {$artisan} redis:monitor --refresh={$refreshInterval}");
|
||||
$this->requireSuccess($this->paneManager->respawnPane($pane, "{$envPrefix}php {$artisan} redis:monitor --refresh={$refreshInterval}"), 'respawn Redis pane');
|
||||
}
|
||||
}
|
||||
$windowIndex++;
|
||||
@@ -317,8 +384,78 @@ class TmuxLayoutBuilder
|
||||
|
||||
// bash console
|
||||
if ((int) Settings::settingValue('console') === 1) {
|
||||
$this->paneManager->createWindow($windowIndex, $this->getPaneDisplayName('bash'));
|
||||
$this->paneManager->respawnPane("{$windowIndex}.0", 'bash -i');
|
||||
$pane = $this->createWindowPane($windowIndex, $this->getPaneDisplayName('bash'), TmuxPaneRole::Console, $this->getPaneDisplayName('bash'));
|
||||
$this->requireSuccess($this->paneManager->respawnPane($pane, 'bash -i'), 'respawn console pane');
|
||||
}
|
||||
}
|
||||
|
||||
public function lastError(): ?string
|
||||
{
|
||||
return $this->lastError;
|
||||
}
|
||||
|
||||
private function createSessionPane(TmuxPaneRole $role, string $title): string
|
||||
{
|
||||
$paneId = $this->sessionManager->createSession($title);
|
||||
if ($paneId === null) {
|
||||
throw new RuntimeException($this->sessionManager->lastError() ?? 'Unable to create tmux session.');
|
||||
}
|
||||
|
||||
$this->sessionCreated = true;
|
||||
$this->configurePane($paneId, $role, $title);
|
||||
|
||||
return $paneId;
|
||||
}
|
||||
|
||||
private function createWindowPane(int $index, string $windowName, TmuxPaneRole $role, string $title): string
|
||||
{
|
||||
$paneId = $this->paneManager->createWindow($index, $windowName);
|
||||
if ($paneId === null) {
|
||||
throw new RuntimeException($this->paneManager->lastError() ?? "Unable to create tmux window {$index}.");
|
||||
}
|
||||
|
||||
$this->configurePane($paneId, $role, $title);
|
||||
|
||||
return $paneId;
|
||||
}
|
||||
|
||||
private function splitHorizontal(string $target, int $percentage, TmuxPaneRole $role, string $title): string
|
||||
{
|
||||
$paneId = $this->paneManager->splitHorizontal($target, $percentage);
|
||||
if ($paneId === null) {
|
||||
throw new RuntimeException($this->paneManager->lastError() ?? "Unable to split tmux pane {$target}.");
|
||||
}
|
||||
|
||||
$this->configurePane($paneId, $role, $title);
|
||||
|
||||
return $paneId;
|
||||
}
|
||||
|
||||
private function splitVertical(string $target, int $percentage, TmuxPaneRole $role, string $title): string
|
||||
{
|
||||
$paneId = $this->paneManager->splitVertical($target, $percentage);
|
||||
if ($paneId === null) {
|
||||
throw new RuntimeException($this->paneManager->lastError() ?? "Unable to split tmux pane {$target}.");
|
||||
}
|
||||
|
||||
$this->configurePane($paneId, $role, $title);
|
||||
|
||||
return $paneId;
|
||||
}
|
||||
|
||||
private function configurePane(string $paneId, TmuxPaneRole $role, string $title): void
|
||||
{
|
||||
$this->requireSuccess($this->paneManager->setPaneTitle($paneId, $title), "set title for {$role->value} pane");
|
||||
$this->requireSuccess($this->paneManager->setPaneRole($paneId, $role), "tag {$role->value} pane");
|
||||
}
|
||||
|
||||
private function requireSuccess(bool $successful, string $operation): void
|
||||
{
|
||||
if (! $successful) {
|
||||
$error = $this->paneManager->lastError();
|
||||
$suffix = $error === null || $error === '' ? '' : ": {$error}";
|
||||
|
||||
throw new RuntimeException("Unable to {$operation}{$suffix}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,7 +465,7 @@ class TmuxLayoutBuilder
|
||||
protected function commandExists(string $command): bool
|
||||
{
|
||||
$result = Process::timeout(5)
|
||||
->run("which {$command} 2>/dev/null");
|
||||
->run(['which', $command]);
|
||||
|
||||
return $result->successful() && str_contains($result->output(), $command);
|
||||
}
|
||||
|
||||
@@ -4,15 +4,27 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Tmux;
|
||||
|
||||
use App\Enums\TmuxPaneRole;
|
||||
use Illuminate\Contracts\Process\ProcessResult;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Service for managing individual tmux panes
|
||||
*/
|
||||
class TmuxPaneManager
|
||||
{
|
||||
private const ROLE_OPTION = '@nntmux_role';
|
||||
|
||||
protected string $sessionName;
|
||||
|
||||
/**
|
||||
* @var array<string, string>|null
|
||||
*/
|
||||
private ?array $roleTargets = null;
|
||||
|
||||
private ?string $lastError = null;
|
||||
|
||||
public function __construct(string $sessionName)
|
||||
{
|
||||
$this->sessionName = $sessionName;
|
||||
@@ -21,48 +33,37 @@ class TmuxPaneManager
|
||||
/**
|
||||
* Create a new window
|
||||
*/
|
||||
public function createWindow(int $index, string $name): bool
|
||||
public function createWindow(int $index, string $name): ?string
|
||||
{
|
||||
// Escape single quotes in the name for shell
|
||||
$escapedName = str_replace("'", "'\\''", $name);
|
||||
|
||||
$result = Process::timeout(10)->run(
|
||||
"tmux new-window -t {$this->sessionName}:{$index} -n '{$escapedName}' 'printf \"\\033]2;{$escapedName}\\033\"'"
|
||||
['tmux', 'new-window', '-P', '-F', '#{pane_id}', '-t', "{$this->sessionName}:{$index}", '-n', $name, 'true']
|
||||
);
|
||||
|
||||
return $result->successful();
|
||||
return $this->createdPaneId($result->successful(), $result->output(), $result->errorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a pane horizontally
|
||||
*/
|
||||
public function splitHorizontal(string $target, string $percentage, string $title = ''): bool
|
||||
public function splitHorizontal(string $target, int $percentage): ?string
|
||||
{
|
||||
// Escape single quotes in the title for shell
|
||||
$escapedTitle = str_replace("'", "'\\''", $title);
|
||||
$titleCmd = $title ? "printf \"\\033]2;{$escapedTitle}\\033\"" : '';
|
||||
|
||||
$result = Process::timeout(10)->run(
|
||||
"tmux splitw -t {$this->sessionName}:{$target} -h -l {$percentage} '{$titleCmd}'"
|
||||
['tmux', 'split-window', '-P', '-F', '#{pane_id}', '-t', $this->target($target), '-h', '-l', "{$percentage}%", 'true']
|
||||
);
|
||||
|
||||
return $result->successful();
|
||||
return $this->createdPaneId($result->successful(), $result->output(), $result->errorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a pane vertically
|
||||
*/
|
||||
public function splitVertical(string $target, string $percentage, string $title = ''): bool
|
||||
public function splitVertical(string $target, int $percentage): ?string
|
||||
{
|
||||
// Escape single quotes in the title for shell
|
||||
$escapedTitle = str_replace("'", "'\\''", $title);
|
||||
$titleCmd = $title ? "printf \"\\033]2;{$escapedTitle}\\033\"" : '';
|
||||
|
||||
$result = Process::timeout(10)->run(
|
||||
"tmux splitw -t {$this->sessionName}:{$target} -v -l {$percentage} '{$titleCmd}'"
|
||||
['tmux', 'split-window', '-P', '-F', '#{pane_id}', '-t', $this->target($target), '-v', '-l', "{$percentage}%", 'true']
|
||||
);
|
||||
|
||||
return $result->successful();
|
||||
return $this->createdPaneId($result->successful(), $result->output(), $result->errorOutput());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,10 +72,10 @@ class TmuxPaneManager
|
||||
public function selectPane(string $target): bool
|
||||
{
|
||||
$result = Process::timeout(5)->run(
|
||||
"tmux selectp -t {$this->sessionName}:{$target}"
|
||||
['tmux', 'select-pane', '-t', $this->target($target)]
|
||||
);
|
||||
|
||||
return $result->successful();
|
||||
return $this->recordResult($result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,10 +84,10 @@ class TmuxPaneManager
|
||||
public function selectWindow(int $window): bool
|
||||
{
|
||||
$result = Process::timeout(5)->run(
|
||||
"tmux select-window -t {$this->sessionName}:{$window}"
|
||||
['tmux', 'select-window', '-t', "{$this->sessionName}:{$window}"]
|
||||
);
|
||||
|
||||
return $result->successful();
|
||||
return $this->recordResult($result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,17 +95,17 @@ class TmuxPaneManager
|
||||
*/
|
||||
public function respawnPane(string $target, string $command, bool $kill = false): bool
|
||||
{
|
||||
$killFlag = $kill ? '-k' : '';
|
||||
|
||||
// Escape the command for tmux by replacing double quotes with escaped quotes
|
||||
$escapedCommand = str_replace('"', '\\"', $command);
|
||||
$escapedCommand = str_replace('$', '\\$', $escapedCommand);
|
||||
$arguments = ['tmux', 'respawn-pane'];
|
||||
if ($kill) {
|
||||
$arguments[] = '-k';
|
||||
}
|
||||
array_push($arguments, '-t', $this->target($target), $command);
|
||||
|
||||
$result = Process::timeout(10)->run(
|
||||
"tmux respawnp {$killFlag} -t {$this->sessionName}:{$target} \"{$escapedCommand}\""
|
||||
$arguments
|
||||
);
|
||||
|
||||
return $result->successful();
|
||||
return $this->recordResult($result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,13 +113,14 @@ class TmuxPaneManager
|
||||
*/
|
||||
public function sendKeys(string $target, string $keys, bool $enter = true): bool
|
||||
{
|
||||
$enterKey = $enter ? ' Enter' : '';
|
||||
|
||||
$result = Process::timeout(5)->run(
|
||||
"tmux send-keys -t {$this->sessionName}:{$target} '{$keys}'{$enterKey}"
|
||||
array_values(array_filter(
|
||||
['tmux', 'send-keys', '-t', $this->target($target), '--', $keys, $enter ? 'Enter' : null],
|
||||
static fn (?string $argument): bool => $argument !== null,
|
||||
))
|
||||
);
|
||||
|
||||
return $result->successful();
|
||||
return $this->recordResult($result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,10 +129,10 @@ class TmuxPaneManager
|
||||
public function killPane(string $target): bool
|
||||
{
|
||||
$result = Process::timeout(5)->run(
|
||||
"tmux kill-pane -t {$this->sessionName}:{$target}"
|
||||
['tmux', 'kill-pane', '-t', $this->target($target)]
|
||||
);
|
||||
|
||||
return $result->successful();
|
||||
return $this->recordResult($result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,14 +140,11 @@ class TmuxPaneManager
|
||||
*/
|
||||
public function setPaneTitle(string $target, string $title): bool
|
||||
{
|
||||
// Escape single quotes in the title for shell
|
||||
$escapedTitle = str_replace("'", "'\\''", $title);
|
||||
|
||||
$result = Process::timeout(5)->run(
|
||||
"tmux select-pane -t {$this->sessionName}:{$target} -T '{$escapedTitle}'"
|
||||
['tmux', 'select-pane', '-t', $this->target($target), '-T', $title]
|
||||
);
|
||||
|
||||
return $result->successful();
|
||||
return $this->recordResult($result);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,7 +153,7 @@ class TmuxPaneManager
|
||||
public function getPaneTitle(string $target): ?string
|
||||
{
|
||||
$result = Process::timeout(5)->run(
|
||||
"tmux display-message -p -t {$this->sessionName}:{$target} '#{pane_title}'"
|
||||
['tmux', 'display-message', '-p', '-t', $this->target($target), '#{pane_title}']
|
||||
);
|
||||
|
||||
return $result->successful() ? trim($result->output()) : null;
|
||||
@@ -166,9 +165,146 @@ class TmuxPaneManager
|
||||
public function capturePane(string $target, int $lines = 100): string
|
||||
{
|
||||
$result = Process::timeout(10)->run(
|
||||
"tmux capture-pane -p -t {$this->sessionName}:{$target} -S -{$lines}"
|
||||
['tmux', 'capture-pane', '-p', '-t', $this->target($target), '-S', "-{$lines}"]
|
||||
);
|
||||
|
||||
return $result->successful() ? $result->output() : '';
|
||||
}
|
||||
|
||||
public function setPaneRole(string $target, TmuxPaneRole $role): bool
|
||||
{
|
||||
$result = Process::timeout(5)->run(
|
||||
['tmux', 'set-option', '-p', '-t', $this->target($target), self::ROLE_OPTION, $role->value]
|
||||
);
|
||||
|
||||
if ($this->recordResult($result)) {
|
||||
$this->roleTargets = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function paneForRole(TmuxPaneRole $role, ?string $legacyTarget = null): string
|
||||
{
|
||||
$targets = $this->roleTargets();
|
||||
|
||||
if (isset($targets[$role->value])) {
|
||||
return $targets[$role->value];
|
||||
}
|
||||
|
||||
if ($legacyTarget !== null) {
|
||||
return $this->tagLegacyPane($role, $legacyTarget);
|
||||
}
|
||||
|
||||
throw new RuntimeException("Tmux pane role '{$role->value}' was not found in session '{$this->sessionName}'.");
|
||||
}
|
||||
|
||||
public function lastError(): ?string
|
||||
{
|
||||
return $this->lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function roleTargets(): array
|
||||
{
|
||||
if ($this->roleTargets !== null) {
|
||||
return $this->roleTargets;
|
||||
}
|
||||
|
||||
$result = Process::timeout(10)->run(
|
||||
['tmux', 'list-panes', '-s', '-t', $this->sessionName, '-F', "#{pane_id}\t#{".self::ROLE_OPTION.'}']
|
||||
);
|
||||
|
||||
if (! $result->successful()) {
|
||||
throw new RuntimeException(
|
||||
"Unable to list panes for tmux session '{$this->sessionName}': ".trim($result->errorOutput())
|
||||
);
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
foreach (preg_split('/\R/', trim($result->output())) ?: [] as $line) {
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$paneId, $role] = array_pad(explode("\t", $line, 2), 2, '');
|
||||
if ($role === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($targets[$role])) {
|
||||
throw new RuntimeException(
|
||||
"Tmux pane role '{$role}' is assigned more than once in session '{$this->sessionName}'."
|
||||
);
|
||||
}
|
||||
|
||||
$targets[$role] = $paneId;
|
||||
}
|
||||
|
||||
return $this->roleTargets = $targets;
|
||||
}
|
||||
|
||||
private function target(string $target): string
|
||||
{
|
||||
return str_starts_with($target, '%') ? $target : "{$this->sessionName}:{$target}";
|
||||
}
|
||||
|
||||
private function tagLegacyPane(TmuxPaneRole $role, string $legacyTarget): string
|
||||
{
|
||||
$result = Process::timeout(5)->run(
|
||||
['tmux', 'display-message', '-p', '-t', $this->target($legacyTarget), '#{pane_id}']
|
||||
);
|
||||
$paneId = trim($result->output());
|
||||
|
||||
if (! $result->successful() || ! preg_match('/^%[0-9]+$/', $paneId)) {
|
||||
throw new RuntimeException(
|
||||
"Tmux pane role '{$role->value}' and legacy target '{$legacyTarget}' were not found in session '{$this->sessionName}'."
|
||||
);
|
||||
}
|
||||
|
||||
if (! $this->setPaneRole($paneId, $role)) {
|
||||
throw new RuntimeException(
|
||||
"Unable to tag legacy tmux pane '{$legacyTarget}' as '{$role->value}': {$this->lastError}."
|
||||
);
|
||||
}
|
||||
|
||||
return $paneId;
|
||||
}
|
||||
|
||||
private function createdPaneId(bool $successful, string $output, string $errorOutput): ?string
|
||||
{
|
||||
if (! $successful) {
|
||||
$this->lastError = trim($errorOutput);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$paneId = trim($output);
|
||||
if (! preg_match('/^%[0-9]+$/', $paneId)) {
|
||||
$this->lastError = "Tmux returned an invalid pane ID: '{$paneId}'.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->lastError = null;
|
||||
|
||||
return $paneId;
|
||||
}
|
||||
|
||||
private function recordResult(ProcessResult $result): bool
|
||||
{
|
||||
if ($result->successful()) {
|
||||
$this->lastError = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$this->lastError = trim($result->errorOutput());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ class TmuxSessionManager
|
||||
|
||||
protected string $configFile;
|
||||
|
||||
private ?string $lastError = null;
|
||||
|
||||
public function __construct(?string $sessionName = null)
|
||||
{
|
||||
$this->sessionName = $sessionName ?? $this->getSessionName();
|
||||
@@ -32,13 +34,18 @@ class TmuxSessionManager
|
||||
?? config('tmux.session.default_name', 'nntmux');
|
||||
}
|
||||
|
||||
public function sessionName(): string
|
||||
{
|
||||
return $this->sessionName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tmux session exists
|
||||
*/
|
||||
public function sessionExists(): bool
|
||||
{
|
||||
$result = Process::timeout(10)
|
||||
->run("tmux list-sessions 2>/dev/null | grep -q '^{$this->sessionName}:'");
|
||||
->run(['tmux', 'has-session', '-t', $this->sessionName]);
|
||||
|
||||
return $result->successful();
|
||||
}
|
||||
@@ -46,22 +53,50 @@ class TmuxSessionManager
|
||||
/**
|
||||
* Create a new tmux session
|
||||
*/
|
||||
public function createSession(string $windowName = 'Monitor'): bool
|
||||
public function createSession(string $windowName = 'Monitor'): ?string
|
||||
{
|
||||
if ($this->sessionExists()) {
|
||||
return false;
|
||||
$this->lastError = "Tmux session '{$this->sessionName}' already exists.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$configOption = file_exists($this->configFile) ? "-f {$this->configFile}" : '';
|
||||
|
||||
// Escape single quotes in the window name for shell
|
||||
$escapedWindowName = str_replace("'", "'\\''", $windowName);
|
||||
|
||||
$result = Process::timeout(30)->run(
|
||||
"tmux {$configOption} new-session -d -s {$this->sessionName} -n '{$escapedWindowName}' 'printf \"\\033]2;{$escapedWindowName}\\033\"'"
|
||||
$arguments = ['tmux'];
|
||||
if (file_exists($this->configFile)) {
|
||||
array_push($arguments, '-f', $this->configFile);
|
||||
}
|
||||
array_push(
|
||||
$arguments,
|
||||
'new-session',
|
||||
'-d',
|
||||
'-P',
|
||||
'-F',
|
||||
'#{pane_id}',
|
||||
'-s',
|
||||
$this->sessionName,
|
||||
'-n',
|
||||
$windowName,
|
||||
'true',
|
||||
);
|
||||
|
||||
return $result->successful();
|
||||
$result = Process::timeout(30)->run($arguments);
|
||||
|
||||
if (! $result->successful()) {
|
||||
$this->lastError = trim($result->errorOutput());
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$paneId = trim($result->output());
|
||||
if (! preg_match('/^%[0-9]+$/', $paneId)) {
|
||||
$this->lastError = "Tmux returned an invalid pane ID: '{$paneId}'.";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->lastError = null;
|
||||
|
||||
return $paneId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +108,7 @@ class TmuxSessionManager
|
||||
return true;
|
||||
}
|
||||
|
||||
$result = Process::timeout(30)->run("tmux kill-session -t {$this->sessionName}");
|
||||
$result = Process::timeout(30)->run(['tmux', 'kill-session', '-t', $this->sessionName]);
|
||||
|
||||
return $result->successful();
|
||||
}
|
||||
@@ -87,11 +122,9 @@ class TmuxSessionManager
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = Process::timeout(5)->run(
|
||||
"tmux select-window -t {$this->sessionName}:0; tmux attach-session -d -t {$this->sessionName}"
|
||||
);
|
||||
passthru('tmux attach-session -t '.escapeshellarg($this->sessionName), $exitCode);
|
||||
|
||||
return $result->successful();
|
||||
return $exitCode === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,7 +139,7 @@ class TmuxSessionManager
|
||||
}
|
||||
|
||||
$result = Process::timeout(10)->run(
|
||||
"tmux list-panes -s -t {$this->sessionName} -F '#{window_index}:#{pane_index} #{pane_title}'"
|
||||
['tmux', 'list-panes', '-s', '-t', $this->sessionName, '-F', "#{window_index}:#{pane_index}\t#{pane_title}"]
|
||||
);
|
||||
|
||||
if (! $result->successful()) {
|
||||
@@ -121,7 +154,7 @@ class TmuxSessionManager
|
||||
continue;
|
||||
}
|
||||
|
||||
[$position, $title] = explode(' ', $line, 2);
|
||||
[$position, $title] = array_pad(explode("\t", $line, 2), 2, '');
|
||||
$panes[$position] = $title;
|
||||
}
|
||||
|
||||
@@ -134,7 +167,7 @@ class TmuxSessionManager
|
||||
public function getPaneStatus(string $window, string $pane): ?string
|
||||
{
|
||||
$result = Process::timeout(5)->run(
|
||||
"tmux display-message -p -t {$this->sessionName}:{$window}.{$pane} '#{pane_current_command}'"
|
||||
['tmux', 'display-message', '-p', '-t', "{$this->sessionName}:{$window}.{$pane}", '#{pane_current_command}']
|
||||
);
|
||||
|
||||
return $result->successful() ? trim($result->output()) : null;
|
||||
@@ -149,4 +182,9 @@ class TmuxSessionManager
|
||||
|
||||
return $status !== null && $status !== 'bash' && $status !== 'sh';
|
||||
}
|
||||
|
||||
public function lastError(): ?string
|
||||
{
|
||||
return $this->lastError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Tmux;
|
||||
|
||||
use App\Enums\TmuxPaneRole;
|
||||
use App\Models\Settings;
|
||||
|
||||
/**
|
||||
@@ -49,15 +50,18 @@ class TmuxTaskRunner
|
||||
*/
|
||||
public function runTask(string $taskName, array $config): bool
|
||||
{
|
||||
$pane = $config['pane'] ?? null;
|
||||
$role = $config['role'] ?? null;
|
||||
$command = $config['command'] ?? null;
|
||||
$enabled = $config['enabled'] ?? true;
|
||||
$workAvailable = $config['work_available'] ?? true;
|
||||
|
||||
if (! $pane || ! $command) {
|
||||
if (! $role instanceof TmuxPaneRole || ! $command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$legacyPane = $config['legacy_pane'] ?? null;
|
||||
$pane = $this->paneManager->paneForRole($role, is_string($legacyPane) ? $legacyPane : null);
|
||||
|
||||
// Check if task is enabled and has work
|
||||
if (! $enabled) {
|
||||
return $this->disablePane($pane, $taskName, 'disabled in settings');
|
||||
@@ -183,7 +187,7 @@ class TmuxTaskRunner
|
||||
public function runIRCScraper(array $config): bool
|
||||
{
|
||||
$runScraper = (int) ($config['constants']['run_ircscraper'] ?? 0);
|
||||
$pane = '3.0';
|
||||
$pane = $this->paneManager->paneForRole(TmuxPaneRole::IrcScraper, '3.0');
|
||||
|
||||
if ($runScraper !== 1) {
|
||||
return $this->disablePane($pane, 'IRC Scraper', 'disabled in settings');
|
||||
@@ -206,7 +210,7 @@ class TmuxTaskRunner
|
||||
{
|
||||
$enabled = (int) ($config['settings']['binaries_run'] ?? 0);
|
||||
$killswitch = $config['killswitch']['pp'] ?? false;
|
||||
$pane = '0.1';
|
||||
$pane = $this->paneManager->paneForRole(TmuxPaneRole::Binaries, '0.1');
|
||||
|
||||
if (! $enabled) {
|
||||
return $this->disablePane($pane, 'Update Binaries', 'disabled in settings');
|
||||
@@ -243,7 +247,7 @@ class TmuxTaskRunner
|
||||
$enabled = (int) ($config['settings']['backfill'] ?? 0);
|
||||
$collKillswitch = $config['killswitch']['coll'] ?? false;
|
||||
$ppKillswitch = $config['killswitch']['pp'] ?? false;
|
||||
$pane = '0.2';
|
||||
$pane = $this->paneManager->paneForRole(TmuxPaneRole::Backfill, '0.2');
|
||||
|
||||
if (! $enabled) {
|
||||
return $this->disablePane($pane, 'Backfill', 'disabled in settings');
|
||||
@@ -287,7 +291,8 @@ class TmuxTaskRunner
|
||||
public function runReleasesUpdate(array $config): bool
|
||||
{
|
||||
$enabled = (int) ($config['settings']['releases_run'] ?? 0);
|
||||
$pane = $config['pane'] ?? '0.3';
|
||||
$legacyPane = (int) ($config['constants']['sequential'] ?? 0) === 1 ? '0.1' : '0.3';
|
||||
$pane = $this->paneManager->paneForRole(TmuxPaneRole::Releases, $legacyPane);
|
||||
|
||||
if (! $enabled) {
|
||||
return $this->disablePane($pane, 'Update Releases', 'disabled in settings');
|
||||
@@ -354,7 +359,7 @@ class TmuxTaskRunner
|
||||
// For now, delegate to existing methods
|
||||
$this->runBinariesUpdate($runVar);
|
||||
$this->runBackfill($runVar);
|
||||
$this->runReleasesUpdate(array_merge($runVar, ['pane' => '0.3']));
|
||||
$this->runReleasesUpdate($runVar);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -366,7 +371,7 @@ class TmuxTaskRunner
|
||||
*/
|
||||
protected function runMainBasic(array $runVar): bool
|
||||
{
|
||||
return $this->runReleasesUpdate(array_merge($runVar, ['pane' => '0.1']));
|
||||
return $this->runReleasesUpdate($runVar);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -377,7 +382,7 @@ class TmuxTaskRunner
|
||||
protected function runMainSequential(array $runVar): bool
|
||||
{
|
||||
// Full sequential mode - runs group:update-all for each group
|
||||
$pane = '0.1';
|
||||
$pane = $this->paneManager->paneForRole(TmuxPaneRole::Sequential, '0.1');
|
||||
|
||||
$niceness = $this->getNiceness();
|
||||
$artisan = base_path('artisan');
|
||||
@@ -396,7 +401,7 @@ class TmuxTaskRunner
|
||||
{
|
||||
$enabled = (int) ($runVar['settings']['fix_names'] ?? 0);
|
||||
$work = (int) ($runVar['counts']['now']['processrenames'] ?? 0);
|
||||
$pane = '1.0';
|
||||
$pane = $this->paneManager->paneForRole(TmuxPaneRole::FixNames, '1.0');
|
||||
|
||||
if ($enabled !== 1) {
|
||||
return $this->disablePane($pane, 'Fix Release Names', 'disabled in settings');
|
||||
@@ -431,7 +436,7 @@ class TmuxTaskRunner
|
||||
protected function runRemoveCrapTask(array $runVar): bool
|
||||
{
|
||||
$option = $runVar['settings']['fix_crap_opt'] ?? 'Disabled';
|
||||
$pane = '1.1';
|
||||
$pane = $this->paneManager->paneForRole(TmuxPaneRole::RemoveCrap, '1.1');
|
||||
|
||||
// Handle disabled state
|
||||
if ($option === 'Disabled' || $option === 0 || $option === '0') {
|
||||
@@ -545,7 +550,7 @@ class TmuxTaskRunner
|
||||
protected function runPostProcessAdditional(array $runVar): bool
|
||||
{
|
||||
$postSetting = (int) ($runVar['settings']['post'] ?? 0);
|
||||
$pane = '2.0';
|
||||
$pane = $this->paneManager->paneForRole(TmuxPaneRole::PostAdditional, '2.0');
|
||||
|
||||
// Check if post processing is enabled (1 = additional, 2 = nfo, 3 = both)
|
||||
if ($postSetting === 0) {
|
||||
@@ -610,7 +615,7 @@ class TmuxTaskRunner
|
||||
protected function runTvTask(array $runVar): bool
|
||||
{
|
||||
$enabled = (int) ($runVar['settings']['post_non'] ?? 0);
|
||||
$pane = '2.1';
|
||||
$pane = $this->paneManager->paneForRole(TmuxPaneRole::PostTv, '2.1');
|
||||
|
||||
if ($enabled !== 1) {
|
||||
return $this->disablePane($pane, 'Post-process TV/Anime', 'disabled in settings');
|
||||
@@ -670,7 +675,7 @@ class TmuxTaskRunner
|
||||
protected function runMoviesTask(array $runVar): bool
|
||||
{
|
||||
$enabled = (int) ($runVar['settings']['post_non'] ?? 0);
|
||||
$pane = '2.3';
|
||||
$pane = $this->paneManager->paneForRole(TmuxPaneRole::PostMovies, '2.3');
|
||||
|
||||
if ($enabled !== 1) {
|
||||
return $this->disablePane($pane, 'Post-process Movies', 'disabled in settings');
|
||||
@@ -720,7 +725,8 @@ class TmuxTaskRunner
|
||||
protected function runAmazonTask(array $runVar): bool
|
||||
{
|
||||
$enabled = (int) ($runVar['settings']['post_amazon'] ?? 0);
|
||||
$pane = '2.2';
|
||||
$legacyPane = (int) ($runVar['constants']['sequential'] ?? 0) === 2 ? '1.1' : '2.2';
|
||||
$pane = $this->paneManager->paneForRole(TmuxPaneRole::PostMetadata, $legacyPane);
|
||||
|
||||
if ($enabled !== 1) {
|
||||
return $this->disablePane($pane, 'Post-process Metadata', 'disabled in settings');
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Process\PendingProcess;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use PDO;
|
||||
use Tests\TestCase;
|
||||
@@ -88,7 +89,7 @@ class TmuxHealthCheckCommandTest extends TestCase
|
||||
->expectsOutputToContain("Tmux session 'test-session' does not exist.")
|
||||
->assertExitCode(0);
|
||||
|
||||
Process::assertRanTimes("tmux list-sessions 2>/dev/null | grep -q '^test-session:'", 1);
|
||||
Process::assertRanTimes(fn (PendingProcess $process): bool => $this->isTmuxCommand($process, 'has-session'), 1);
|
||||
Process::assertNotRan('which tmux 2>/dev/null');
|
||||
}
|
||||
|
||||
@@ -101,7 +102,7 @@ class TmuxHealthCheckCommandTest extends TestCase
|
||||
->expectsOutputToContain("Tmux session 'test-session' does not exist.")
|
||||
->assertExitCode(1);
|
||||
|
||||
Process::assertRanTimes("tmux list-sessions 2>/dev/null | grep -q '^test-session:'", 1);
|
||||
Process::assertRanTimes(fn (PendingProcess $process): bool => $this->isTmuxCommand($process, 'has-session'), 1);
|
||||
Process::assertNotRan('which tmux 2>/dev/null');
|
||||
}
|
||||
|
||||
@@ -115,25 +116,50 @@ class TmuxHealthCheckCommandTest extends TestCase
|
||||
->expectsOutputToContain("Tmux session 'test-session' restarted successfully.")
|
||||
->assertExitCode(0);
|
||||
|
||||
Process::assertRanTimes("tmux list-sessions 2>/dev/null | grep -q '^test-session:'", 4);
|
||||
Process::assertRanTimes(fn (PendingProcess $process): bool => $this->isTmuxCommand($process, 'has-session'), 4);
|
||||
Process::assertRan('which tmux 2>/dev/null');
|
||||
Process::assertRan(fn ($process): bool => str_contains($process->command, 'new-session -d -s test-session'));
|
||||
Process::assertRan(fn (PendingProcess $process): bool => $this->isTmuxCommand($process, 'new-session'));
|
||||
}
|
||||
|
||||
private function fakeMissingSession(): void
|
||||
{
|
||||
Process::fake([
|
||||
"tmux list-sessions 2>/dev/null | grep -q '^test-session:'" => Process::result('', '', 1),
|
||||
]);
|
||||
Process::fake(fn () => Process::result('', '', 1));
|
||||
}
|
||||
|
||||
private function fakeMissingSessionWithSuccessfulStart(): void
|
||||
{
|
||||
Process::fake([
|
||||
"tmux list-sessions 2>/dev/null | grep -q '^test-session:'" => Process::result('', '', 1),
|
||||
'which tmux 2>/dev/null' => Process::result('/usr/bin/tmux'.PHP_EOL),
|
||||
'*' => Process::result(),
|
||||
]);
|
||||
$nextPaneId = 1;
|
||||
|
||||
Process::fake(function (PendingProcess $process) use (&$nextPaneId) {
|
||||
if ($process->command === 'which tmux 2>/dev/null') {
|
||||
return Process::result('/usr/bin/tmux'.PHP_EOL);
|
||||
}
|
||||
|
||||
if (! is_array($process->command)) {
|
||||
return Process::result();
|
||||
}
|
||||
|
||||
if (in_array('has-session', $process->command, true)) {
|
||||
return Process::result('', '', 1);
|
||||
}
|
||||
|
||||
if (in_array('new-session', $process->command, true)
|
||||
|| in_array('new-window', $process->command, true)
|
||||
|| in_array('split-window', $process->command, true)) {
|
||||
return Process::result('%'.$nextPaneId++."\n");
|
||||
}
|
||||
|
||||
if (in_array('list-panes', $process->command, true)) {
|
||||
return Process::result("%1\tmonitor\n");
|
||||
}
|
||||
|
||||
return Process::result();
|
||||
});
|
||||
}
|
||||
|
||||
private function isTmuxCommand(PendingProcess $process, string $command): bool
|
||||
{
|
||||
return is_array($process->command) && in_array($command, $process->command, true);
|
||||
}
|
||||
|
||||
private function setSetting(string $name, string $value): void
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Enums\TmuxPaneRole;
|
||||
use App\Services\Tmux\TmuxLayoutBuilder;
|
||||
use App\Services\Tmux\TmuxPaneManager;
|
||||
use App\Services\Tmux\TmuxSessionManager;
|
||||
use App\Services\Tmux\TmuxTaskRunner;
|
||||
use Illuminate\Process\PendingProcess;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TmuxPaneManagementTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Process::preventStrayProcesses();
|
||||
}
|
||||
|
||||
public function test_roles_resolve_to_stable_pane_ids(): void
|
||||
{
|
||||
Process::fake([
|
||||
'*' => Process::result("%12\tmonitor\n%27\tpost_movies\n"),
|
||||
]);
|
||||
|
||||
$manager = new TmuxPaneManager('test session');
|
||||
|
||||
$this->assertSame('%12', $manager->paneForRole(TmuxPaneRole::Monitor));
|
||||
$this->assertSame('%27', $manager->paneForRole(TmuxPaneRole::PostMovies));
|
||||
|
||||
Process::assertRanTimes(
|
||||
fn (PendingProcess $process): bool => is_array($process->command)
|
||||
&& in_array('list-panes', $process->command, true),
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
public function test_duplicate_roles_are_rejected(): void
|
||||
{
|
||||
Process::fake([
|
||||
'*' => Process::result("%12\tmonitor\n%27\tmonitor\n"),
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage("role 'monitor' is assigned more than once");
|
||||
|
||||
(new TmuxPaneManager('test-session'))->paneForRole(TmuxPaneRole::Monitor);
|
||||
}
|
||||
|
||||
public function test_missing_roles_are_rejected(): void
|
||||
{
|
||||
Process::fake([
|
||||
'*' => Process::result("%12\tmonitor\n"),
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage("role 'post_movies' was not found");
|
||||
|
||||
(new TmuxPaneManager('test-session'))->paneForRole(TmuxPaneRole::PostMovies);
|
||||
}
|
||||
|
||||
public function test_legacy_coordinate_is_tagged_during_role_resolution(): void
|
||||
{
|
||||
Process::fake(function (PendingProcess $process) {
|
||||
if (is_array($process->command) && in_array('list-panes', $process->command, true)) {
|
||||
return Process::result("%8\t\n");
|
||||
}
|
||||
|
||||
if (is_array($process->command) && in_array('display-message', $process->command, true)) {
|
||||
return Process::result("%8\n");
|
||||
}
|
||||
|
||||
return Process::result();
|
||||
});
|
||||
|
||||
$manager = new TmuxPaneManager('legacy-session');
|
||||
|
||||
$this->assertSame('%8', $manager->paneForRole(TmuxPaneRole::Monitor, '0.0'));
|
||||
|
||||
Process::assertRan(function (PendingProcess $process): bool {
|
||||
return $process->command === [
|
||||
'tmux',
|
||||
'set-option',
|
||||
'-p',
|
||||
'-t',
|
||||
'%8',
|
||||
'@nntmux_role',
|
||||
'monitor',
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function test_respawn_passes_the_command_as_one_argument(): void
|
||||
{
|
||||
Process::fake();
|
||||
|
||||
$command = <<<'SH'
|
||||
echo "$HOME" && printf '%s\n' 'quoted value'
|
||||
SH;
|
||||
|
||||
$this->assertTrue((new TmuxPaneManager('test session'))->respawnPane('%42', $command, kill: true));
|
||||
|
||||
Process::assertRan(function (PendingProcess $process) use ($command): bool {
|
||||
return $process->command === [
|
||||
'tmux',
|
||||
'respawn-pane',
|
||||
'-k',
|
||||
'-t',
|
||||
'%42',
|
||||
$command,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
#[DataProvider('layoutRolesProvider')]
|
||||
public function test_layouts_tag_every_logical_pane(int $mode, array $expectedRoles): void
|
||||
{
|
||||
$nextPaneId = 1;
|
||||
$assignedRoles = [];
|
||||
$splitTargets = [];
|
||||
|
||||
Process::fake(function (PendingProcess $process) use (&$nextPaneId, &$assignedRoles, &$splitTargets) {
|
||||
$command = $process->command;
|
||||
if (! is_array($command)) {
|
||||
return Process::result();
|
||||
}
|
||||
|
||||
if (in_array('has-session', $command, true)) {
|
||||
return Process::result('', '', 1);
|
||||
}
|
||||
|
||||
if (in_array('new-session', $command, true)
|
||||
|| in_array('new-window', $command, true)
|
||||
|| in_array('split-window', $command, true)) {
|
||||
if (in_array('split-window', $command, true)) {
|
||||
$splitTargets[] = $command[array_search('-t', $command, true) + 1];
|
||||
}
|
||||
|
||||
return Process::result('%'.$nextPaneId++."\n");
|
||||
}
|
||||
|
||||
$roleOptionIndex = array_search('@nntmux_role', $command, true);
|
||||
if ($roleOptionIndex !== false) {
|
||||
$assignedRoles[] = $command[$roleOptionIndex + 1];
|
||||
}
|
||||
|
||||
return Process::result();
|
||||
});
|
||||
|
||||
$sessionManager = new TmuxSessionManager('test-session');
|
||||
$layoutBuilder = new class($sessionManager) extends TmuxLayoutBuilder
|
||||
{
|
||||
protected function createOptionalWindows(): void {}
|
||||
};
|
||||
|
||||
$this->assertTrue($layoutBuilder->buildLayout($mode), (string) $layoutBuilder->lastError());
|
||||
$this->assertEqualsCanonicalizing($expectedRoles, $assignedRoles);
|
||||
|
||||
foreach ($splitTargets as $target) {
|
||||
$this->assertStringStartsWith('%', $target);
|
||||
}
|
||||
}
|
||||
|
||||
public static function layoutRolesProvider(): array
|
||||
{
|
||||
return [
|
||||
'full' => [
|
||||
0,
|
||||
[
|
||||
'monitor',
|
||||
'binaries',
|
||||
'backfill',
|
||||
'releases',
|
||||
'fix_names',
|
||||
'remove_crap',
|
||||
'post_additional',
|
||||
'post_movies',
|
||||
'post_tv',
|
||||
'post_metadata',
|
||||
'irc_scraper',
|
||||
],
|
||||
],
|
||||
'basic' => [
|
||||
1,
|
||||
[
|
||||
'monitor',
|
||||
'releases',
|
||||
'fix_names',
|
||||
'remove_crap',
|
||||
'post_additional',
|
||||
'post_movies',
|
||||
'post_tv',
|
||||
'post_metadata',
|
||||
'irc_scraper',
|
||||
],
|
||||
],
|
||||
'stripped' => [
|
||||
2,
|
||||
[
|
||||
'monitor',
|
||||
'sequential',
|
||||
'fix_names',
|
||||
'post_metadata',
|
||||
'irc_scraper',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function test_partial_layout_is_removed_after_a_split_failure(): void
|
||||
{
|
||||
$sessionExists = false;
|
||||
$splitAttempts = 0;
|
||||
|
||||
Process::fake(function (PendingProcess $process) use (&$sessionExists, &$splitAttempts) {
|
||||
$command = $process->command;
|
||||
if (! is_array($command)) {
|
||||
return Process::result();
|
||||
}
|
||||
|
||||
if (in_array('has-session', $command, true)) {
|
||||
return Process::result('', '', $sessionExists ? 0 : 1);
|
||||
}
|
||||
|
||||
if (in_array('new-session', $command, true)) {
|
||||
$sessionExists = true;
|
||||
|
||||
return Process::result("%1\n");
|
||||
}
|
||||
|
||||
if (in_array('split-window', $command, true)) {
|
||||
$splitAttempts++;
|
||||
if ($splitAttempts === 2) {
|
||||
return Process::result('', 'split failed', 1);
|
||||
}
|
||||
|
||||
return Process::result("%2\n");
|
||||
}
|
||||
|
||||
if (in_array('kill-session', $command, true)) {
|
||||
$sessionExists = false;
|
||||
}
|
||||
|
||||
return Process::result();
|
||||
});
|
||||
|
||||
$sessionManager = new TmuxSessionManager('test-session');
|
||||
$layoutBuilder = new class($sessionManager) extends TmuxLayoutBuilder
|
||||
{
|
||||
protected function createOptionalWindows(): void {}
|
||||
};
|
||||
|
||||
$this->assertFalse($layoutBuilder->buildLayout(0));
|
||||
$this->assertSame('split failed', $layoutBuilder->lastError());
|
||||
$this->assertFalse($sessionExists);
|
||||
|
||||
Process::assertRan(
|
||||
fn (PendingProcess $process): bool => is_array($process->command)
|
||||
&& in_array('kill-session', $process->command, true),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_task_runner_resolves_a_role_before_respawning(): void
|
||||
{
|
||||
Process::fake(function (PendingProcess $process) {
|
||||
if (is_array($process->command) && in_array('list-panes', $process->command, true)) {
|
||||
return Process::result("%9\tpost_movies\n");
|
||||
}
|
||||
|
||||
return Process::result();
|
||||
});
|
||||
|
||||
$runner = new TmuxTaskRunner('test-session');
|
||||
|
||||
$this->assertTrue($runner->runTask('Movies', [
|
||||
'role' => TmuxPaneRole::PostMovies,
|
||||
'command' => 'php artisan example',
|
||||
]));
|
||||
|
||||
Process::assertRan(function (PendingProcess $process): bool {
|
||||
return $process->command === [
|
||||
'tmux',
|
||||
'respawn-pane',
|
||||
'-t',
|
||||
'%9',
|
||||
'php artisan example',
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user