Backup
This commit is contained in:
parent
3fa5276aba
commit
d4a926ddad
@ -10,6 +10,7 @@ use PHPNative\Ui\Widget\Label;
|
|||||||
use PHPNative\Ui\Widget\Modal;
|
use PHPNative\Ui\Widget\Modal;
|
||||||
use PHPNative\Ui\Widget\TextArea;
|
use PHPNative\Ui\Widget\TextArea;
|
||||||
use PHPNative\Ui\Widget\TextInput;
|
use PHPNative\Ui\Widget\TextInput;
|
||||||
|
use PHPNative\Ui\Widget\ProgressBar;
|
||||||
|
|
||||||
class SftpManagerTab
|
class SftpManagerTab
|
||||||
{
|
{
|
||||||
@ -33,6 +34,14 @@ class SftpManagerTab
|
|||||||
private null|array $currentRemoteSelection = null;
|
private null|array $currentRemoteSelection = null;
|
||||||
private ?string $lastRemoteClickPath = null;
|
private ?string $lastRemoteClickPath = null;
|
||||||
private float $lastRemoteClickTime = 0.0;
|
private float $lastRemoteClickTime = 0.0;
|
||||||
|
private ProgressBar $transferProgressBar;
|
||||||
|
private Label $transferInfoLabel;
|
||||||
|
private array $pendingUploadQueue = [];
|
||||||
|
private int $totalUploadFiles = 0;
|
||||||
|
private int $completedUploadFiles = 0;
|
||||||
|
private array $pendingDownloadQueue = [];
|
||||||
|
private int $totalDownloadFiles = 0;
|
||||||
|
private int $completedDownloadFiles = 0;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string &$apiKey,
|
string &$apiKey,
|
||||||
@ -88,19 +97,26 @@ class SftpManagerTab
|
|||||||
$remoteBrowserContainer->addComponent($this->connectionStatusLabel);
|
$remoteBrowserContainer->addComponent($this->connectionStatusLabel);
|
||||||
|
|
||||||
// Middle: Transfer buttons (Upload/Download)
|
// Middle: Transfer buttons (Upload/Download)
|
||||||
$transferContainer = new Container('flex flex-col justify-center items-center gap-2');
|
$transferContainer = new Container('flex flex-col justify-center items-center gap-2 w-[180]');
|
||||||
|
|
||||||
$uploadButton = new Button(
|
$uploadButton = new Button(
|
||||||
'Hochladen →',
|
'Hochladen →',
|
||||||
'px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700',
|
'w-full px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700',
|
||||||
);
|
);
|
||||||
$downloadButton = new Button(
|
$downloadButton = new Button(
|
||||||
'← Herunterladen',
|
'← Herunterladen',
|
||||||
'px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700',
|
'w-full px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700',
|
||||||
);
|
);
|
||||||
$transferContainer->addComponent($uploadButton);
|
$transferContainer->addComponent($uploadButton);
|
||||||
$transferContainer->addComponent($downloadButton);
|
$transferContainer->addComponent($downloadButton);
|
||||||
|
|
||||||
|
// Transfer-Info + ProgressBar
|
||||||
|
$this->transferInfoLabel = new Label('Kein Transfer aktiv', 'text-xs text-gray-600 mt-2');
|
||||||
|
$this->transferProgressBar = new ProgressBar('mt-1');
|
||||||
|
$this->transferProgressBar->setValue(0.0);
|
||||||
|
$transferContainer->addComponent($this->transferInfoLabel);
|
||||||
|
$transferContainer->addComponent($this->transferProgressBar);
|
||||||
|
|
||||||
$this->tab->addComponent($localBrowserContainer);
|
$this->tab->addComponent($localBrowserContainer);
|
||||||
$this->tab->addComponent($transferContainer);
|
$this->tab->addComponent($transferContainer);
|
||||||
$this->tab->addComponent($remoteBrowserContainer);
|
$this->tab->addComponent($remoteBrowserContainer);
|
||||||
@ -776,16 +792,6 @@ class SftpManagerTab
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($localRow['isDir'] ?? false) === true) {
|
|
||||||
$statusLabel->setText('Ordner-Upload wird noch nicht unterstützt');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_file($localPath) || !is_readable($localPath)) {
|
|
||||||
$statusLabel->setText('Lokale Datei ist nicht lesbar');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($serverListTab->selectedServer === null) {
|
if ($serverListTab->selectedServer === null) {
|
||||||
$statusLabel->setText('Kein Server ausgewählt');
|
$statusLabel->setText('Kein Server ausgewählt');
|
||||||
return;
|
return;
|
||||||
@ -801,13 +807,177 @@ class SftpManagerTab
|
|||||||
$remoteDir = '/';
|
$remoteDir = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
$remotePath = rtrim($remoteDir, '/') . '/' . basename($localPath);
|
// Upload-Größe vorab bestimmen
|
||||||
|
$stats = $this->calculateLocalUploadStats($localPath, $localRow);
|
||||||
|
$totalBytes = $stats['bytes'];
|
||||||
|
$fileCount = $stats['files'];
|
||||||
|
$dirCount = $stats['dirs'];
|
||||||
|
|
||||||
|
$this->transferProgressBar->setValue(0.0);
|
||||||
|
$this->transferInfoLabel->setText(
|
||||||
|
sprintf(
|
||||||
|
'Upload: %d Dateien, %d Ordner (%.2f MB)',
|
||||||
|
$fileCount,
|
||||||
|
$dirCount,
|
||||||
|
$totalBytes > 0 ? ($totalBytes / (1024 * 1024)) : 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Upload-Queue für virtuelle ProgressBar vorbereiten
|
||||||
|
$this->pendingUploadQueue = $this->buildUploadQueue($localPath, $localRow, $remoteDir);
|
||||||
|
$this->totalUploadFiles = count($this->pendingUploadQueue);
|
||||||
|
$this->completedUploadFiles = 0;
|
||||||
|
|
||||||
|
if ($this->totalUploadFiles <= 0) {
|
||||||
|
$this->transferProgressBar->setValue(1.0);
|
||||||
|
$this->transferInfoLabel->setText('Keine Dateien zum Hochladen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Referenz auf ausgewählten Server für spätere Reloads
|
||||||
|
$this->startNextUploadTask($currentPrivateKeyPath, $serverListTab, $statusLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lokale Upload-Statistik (Dateien/Ordner/Bytes) rekursiv berechnen.
|
||||||
|
*/
|
||||||
|
private function calculateLocalUploadStats(string $path, array $row): array
|
||||||
|
{
|
||||||
|
$files = 0;
|
||||||
|
$dirs = 0;
|
||||||
|
$bytes = 0;
|
||||||
|
|
||||||
|
$isDir = (bool) ($row['isDir'] ?? false);
|
||||||
|
|
||||||
|
if (!$isDir) {
|
||||||
|
if (is_file($path) && is_readable($path)) {
|
||||||
|
$files = 1;
|
||||||
|
$bytes = (int) @filesize($path);
|
||||||
|
}
|
||||||
|
return ['files' => $files, 'dirs' => $dirs, 'bytes' => $bytes];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordner: rekursiv zählen
|
||||||
|
$dirs++;
|
||||||
|
|
||||||
|
$iterator = @scandir($path);
|
||||||
|
if ($iterator === false) {
|
||||||
|
return ['files' => $files, 'dirs' => $dirs, 'bytes' => $bytes];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($iterator as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$childPath = $path . DIRECTORY_SEPARATOR . $entry;
|
||||||
|
|
||||||
|
if (is_dir($childPath)) {
|
||||||
|
$childStats = $this->calculateLocalUploadStats($childPath, ['isDir' => true]);
|
||||||
|
$files += $childStats['files'];
|
||||||
|
$dirs += $childStats['dirs'];
|
||||||
|
$bytes += $childStats['bytes'];
|
||||||
|
} elseif (is_file($childPath) && is_readable($childPath)) {
|
||||||
|
$files++;
|
||||||
|
$bytes += (int) @filesize($childPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['files' => $files, 'dirs' => $dirs, 'bytes' => $bytes];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue für Datei-Uploads aufbauen (Datei- oder Ordner-Auswahl).
|
||||||
|
*
|
||||||
|
* @return array<int,array{local:string,remote:string}>
|
||||||
|
*/
|
||||||
|
private function buildUploadQueue(string $localPath, array $row, string $remoteDir): array
|
||||||
|
{
|
||||||
|
$queue = [];
|
||||||
|
$remoteBase = rtrim($remoteDir, '/');
|
||||||
|
if ($remoteBase === '') {
|
||||||
|
$remoteBase = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
$isDir = (bool) ($row['isDir'] ?? false);
|
||||||
|
if (!$isDir) {
|
||||||
|
$queue[] = [
|
||||||
|
'local' => $localPath,
|
||||||
|
'remote' => $remoteBase . '/' . basename($localPath),
|
||||||
|
];
|
||||||
|
return $queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wurzelordner auf Remote
|
||||||
|
$rootRemote = $remoteBase . '/' . basename($localPath);
|
||||||
|
$this->collectUploadFilesRecursive($localPath, $rootRemote, $queue);
|
||||||
|
|
||||||
|
return $queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rekursive Sammlung aller Dateien eines Ordners.
|
||||||
|
*
|
||||||
|
* @param array<int,array{local:string,remote:string}> $queue
|
||||||
|
*/
|
||||||
|
private function collectUploadFilesRecursive(string $localDir, string $remoteDir, array &$queue): void
|
||||||
|
{
|
||||||
|
$entries = @scandir($localDir);
|
||||||
|
if ($entries === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$localChild = $localDir . DIRECTORY_SEPARATOR . $entry;
|
||||||
|
$remoteChild = rtrim($remoteDir, '/') . '/' . $entry;
|
||||||
|
|
||||||
|
if (is_dir($localChild)) {
|
||||||
|
$this->collectUploadFilesRecursive($localChild, $remoteChild, $queue);
|
||||||
|
} elseif (is_file($localChild) && is_readable($localChild)) {
|
||||||
|
$queue[] = [
|
||||||
|
'local' => $localChild,
|
||||||
|
'remote' => $remoteChild,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nächste Datei aus der Upload-Queue hochladen und ProgressBar aktualisieren.
|
||||||
|
*/
|
||||||
|
private function startNextUploadTask(
|
||||||
|
string &$currentPrivateKeyPath,
|
||||||
|
ServerListTab $serverListTab,
|
||||||
|
Label $statusLabel,
|
||||||
|
): void {
|
||||||
|
if ($this->totalUploadFiles <= 0) {
|
||||||
|
$this->transferProgressBar->setValue(1.0);
|
||||||
|
$this->transferInfoLabel->setText('Keine Dateien zum Hochladen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->pendingUploadQueue)) {
|
||||||
|
// Alle Dateien hochgeladen
|
||||||
|
$this->transferProgressBar->setValue(1.0);
|
||||||
|
$this->transferInfoLabel->setText('Upload abgeschlossen');
|
||||||
|
|
||||||
|
$selectedServerRef = &$serverListTab->selectedServer;
|
||||||
|
$this->reloadCurrentDirectory($currentPrivateKeyPath, $selectedServerRef, $statusLabel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = array_shift($this->pendingUploadQueue);
|
||||||
|
$localPath = $item['local'];
|
||||||
|
$remotePath = $item['remote'];
|
||||||
|
|
||||||
// Use reference to selected server for async operation
|
|
||||||
$selectedServerRef = &$serverListTab->selectedServer;
|
$selectedServerRef = &$serverListTab->selectedServer;
|
||||||
$sftpTab = $this;
|
$sftpTab = $this;
|
||||||
|
|
||||||
$uploadAsyncButton = new Button('Upload', '');
|
$uploadAsyncButton = new Button('UploadChunk', '');
|
||||||
$uploadAsyncButton->setOnClickAsync(
|
$uploadAsyncButton->setOnClickAsync(
|
||||||
function () use (&$currentPrivateKeyPath, &$selectedServerRef, $localPath, $remotePath) {
|
function () use (&$currentPrivateKeyPath, &$selectedServerRef, $localPath, $remotePath) {
|
||||||
if ($selectedServerRef === null || empty($currentPrivateKeyPath)) {
|
if ($selectedServerRef === null || empty($currentPrivateKeyPath)) {
|
||||||
@ -824,7 +994,23 @@ class SftpManagerTab
|
|||||||
return ['error' => 'SFTP Login failed'];
|
return ['error' => 'SFTP Login failed'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $sftp->put($remotePath, $localPath, \phpseclib3\Net\SFTP::SOURCE_LOCAL_FILE);
|
// Zielverzeichnis sicherstellen
|
||||||
|
$remoteDir = dirname($remotePath);
|
||||||
|
if (!$sftp->is_dir($remoteDir)) {
|
||||||
|
if (!$sftp->mkdir($remoteDir, -1, true)) {
|
||||||
|
return ['error' => 'Kann Remote-Verzeichnis nicht anlegen'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_file($localPath) || !is_readable($localPath)) {
|
||||||
|
return ['error' => 'Lokale Datei ist nicht lesbar'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $sftp->put(
|
||||||
|
$remotePath,
|
||||||
|
$localPath,
|
||||||
|
\phpseclib3\Net\SFTP::SOURCE_LOCAL_FILE,
|
||||||
|
);
|
||||||
if ($result === false) {
|
if ($result === false) {
|
||||||
return ['error' => 'Upload fehlgeschlagen'];
|
return ['error' => 'Upload fehlgeschlagen'];
|
||||||
}
|
}
|
||||||
@ -834,19 +1020,34 @@ class SftpManagerTab
|
|||||||
return ['error' => $e->getMessage()];
|
return ['error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
function ($result) use ($sftpTab, $statusLabel, &$currentPrivateKeyPath, &$selectedServerRef) {
|
function ($result) use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) {
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
|
$sftpTab->transferInfoLabel->setText('Upload fehlgeschlagen: ' . $result['error']);
|
||||||
$statusLabel->setText('Fehler beim Hochladen: ' . $result['error']);
|
$statusLabel->setText('Fehler beim Hochladen: ' . $result['error']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($result['success'])) {
|
if (isset($result['success'])) {
|
||||||
$statusLabel->setText('Datei erfolgreich hochgeladen');
|
$sftpTab->completedUploadFiles++;
|
||||||
$sftpTab->reloadCurrentDirectory($currentPrivateKeyPath, $selectedServerRef, $statusLabel);
|
$progress = $sftpTab->totalUploadFiles > 0
|
||||||
|
? ($sftpTab->completedUploadFiles / $sftpTab->totalUploadFiles)
|
||||||
|
: 1.0;
|
||||||
|
$sftpTab->transferProgressBar->setValue($progress);
|
||||||
|
$sftpTab->transferInfoLabel->setText(
|
||||||
|
sprintf(
|
||||||
|
'Upload: %d / %d Dateien',
|
||||||
|
$sftpTab->completedUploadFiles,
|
||||||
|
$sftpTab->totalUploadFiles,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nächste Datei starten
|
||||||
|
$sftpTab->startNextUploadTask($currentPrivateKeyPath, $serverListTab, $statusLabel);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
function ($error) use ($statusLabel) {
|
function ($error) use ($sftpTab, $statusLabel) {
|
||||||
$errorMsg = is_string($error) ? $error : 'Unknown error';
|
$errorMsg = is_string($error) ? $error : 'Unknown error';
|
||||||
|
$sftpTab->transferInfoLabel->setText('Upload fehlgeschlagen: ' . $errorMsg);
|
||||||
$statusLabel->setText('Fehler beim Hochladen: ' . $errorMsg);
|
$statusLabel->setText('Fehler beim Hochladen: ' . $errorMsg);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -854,6 +1055,105 @@ class SftpManagerTab
|
|||||||
$uploadAsyncButton->handleMouseClick(0, 0, 0);
|
$uploadAsyncButton->handleMouseClick(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nächste Datei aus der Download-Queue holen und Fortschritt aktualisieren.
|
||||||
|
*/
|
||||||
|
private function startNextDownloadTask(
|
||||||
|
string &$currentPrivateKeyPath,
|
||||||
|
ServerListTab $serverListTab,
|
||||||
|
Label $statusLabel,
|
||||||
|
string $localRootDir,
|
||||||
|
): void {
|
||||||
|
if ($this->totalDownloadFiles <= 0) {
|
||||||
|
$this->transferProgressBar->setValue(1.0);
|
||||||
|
$this->transferInfoLabel->setText('Keine Dateien zum Herunterladen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($this->pendingDownloadQueue)) {
|
||||||
|
// Alle Dateien heruntergeladen
|
||||||
|
$this->transferProgressBar->setValue(1.0);
|
||||||
|
$this->transferInfoLabel->setText('Download abgeschlossen');
|
||||||
|
$this->localFileBrowser->loadDirectory($localRootDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item = array_shift($this->pendingDownloadQueue);
|
||||||
|
$remotePath = $item['remote'];
|
||||||
|
$localPath = $item['local'];
|
||||||
|
|
||||||
|
$selectedServerRef = &$serverListTab->selectedServer;
|
||||||
|
$sftpTab = $this;
|
||||||
|
|
||||||
|
$downloadAsyncButton = new Button('DownloadChunk', '');
|
||||||
|
$downloadAsyncButton->setOnClickAsync(
|
||||||
|
function () use (&$currentPrivateKeyPath, &$selectedServerRef, $remotePath, $localPath) {
|
||||||
|
if ($selectedServerRef === null || empty($currentPrivateKeyPath)) {
|
||||||
|
return ['error' => 'Not connected'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$selectedServer = $selectedServerRef;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$sftp = new \phpseclib3\Net\SFTP($selectedServer['ipv4']);
|
||||||
|
$key = \phpseclib3\Crypt\PublicKeyLoader::load(file_get_contents($currentPrivateKeyPath));
|
||||||
|
|
||||||
|
if (!$sftp->login('root', $key)) {
|
||||||
|
return ['error' => 'SFTP Login failed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$localDir = dirname($localPath);
|
||||||
|
if (!is_dir($localDir)) {
|
||||||
|
if (!mkdir($localDir, 0777, true) && !is_dir($localDir)) {
|
||||||
|
return ['error' => 'Lokales Zielverzeichnis kann nicht erstellt werden'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $sftp->get($remotePath, $localPath);
|
||||||
|
if ($result === false) {
|
||||||
|
return ['error' => 'Download fehlgeschlagen'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return ['error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
function ($result) use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel, $localRootDir) {
|
||||||
|
if (isset($result['error'])) {
|
||||||
|
$sftpTab->transferInfoLabel->setText('Download fehlgeschlagen: ' . $result['error']);
|
||||||
|
$statusLabel->setText('Fehler beim Herunterladen: ' . $result['error']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($result['success'])) {
|
||||||
|
$sftpTab->completedDownloadFiles++;
|
||||||
|
$progress = $sftpTab->totalDownloadFiles > 0
|
||||||
|
? ($sftpTab->completedDownloadFiles / $sftpTab->totalDownloadFiles)
|
||||||
|
: 1.0;
|
||||||
|
$sftpTab->transferProgressBar->setValue($progress);
|
||||||
|
$sftpTab->transferInfoLabel->setText(
|
||||||
|
sprintf(
|
||||||
|
'Download: %d / %d Dateien',
|
||||||
|
$sftpTab->completedDownloadFiles,
|
||||||
|
$sftpTab->totalDownloadFiles,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nächste Datei herunterladen
|
||||||
|
$sftpTab->startNextDownloadTask($currentPrivateKeyPath, $serverListTab, $statusLabel, $localRootDir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
function ($error) use ($sftpTab, $statusLabel) {
|
||||||
|
$errorMsg = is_string($error) ? $error : 'Unknown error';
|
||||||
|
$sftpTab->transferInfoLabel->setText('Download fehlgeschlagen: ' . $errorMsg);
|
||||||
|
$statusLabel->setText('Fehler beim Herunterladen: ' . $errorMsg);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
$downloadAsyncButton->handleMouseClick(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
private function handleDownload(
|
private function handleDownload(
|
||||||
string &$currentPrivateKeyPath,
|
string &$currentPrivateKeyPath,
|
||||||
ServerListTab $serverListTab,
|
ServerListTab $serverListTab,
|
||||||
@ -872,11 +1172,6 @@ class SftpManagerTab
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($remoteRow['isDir'] ?? false) === true) {
|
|
||||||
$statusLabel->setText('Ordner-Download wird noch nicht unterstützt');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($serverListTab->selectedServer === null) {
|
if ($serverListTab->selectedServer === null) {
|
||||||
$statusLabel->setText('Kein Server ausgewählt');
|
$statusLabel->setText('Kein Server ausgewählt');
|
||||||
return;
|
return;
|
||||||
@ -897,15 +1192,16 @@ class SftpManagerTab
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$localPath = rtrim($localDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . basename($remotePath);
|
$this->transferProgressBar->setValue(0.0);
|
||||||
|
$this->transferInfoLabel->setText('Download wird vorbereitet …');
|
||||||
|
|
||||||
// Use reference to selected server for async operation
|
// Async-Scan der Remote-Struktur, um eine Download-Queue aufzubauen
|
||||||
$selectedServerRef = &$serverListTab->selectedServer;
|
$selectedServerRef = &$serverListTab->selectedServer;
|
||||||
$sftpTab = $this;
|
$sftpTab = $this;
|
||||||
|
|
||||||
$downloadAsyncButton = new Button('Download', '');
|
$scanButton = new Button('ScanDownload', '');
|
||||||
$downloadAsyncButton->setOnClickAsync(
|
$scanButton->setOnClickAsync(
|
||||||
function () use (&$currentPrivateKeyPath, &$selectedServerRef, $remotePath, $localPath) {
|
function () use (&$currentPrivateKeyPath, &$selectedServerRef, $remotePath, $remoteRow, $localDir) {
|
||||||
if ($selectedServerRef === null || empty($currentPrivateKeyPath)) {
|
if ($selectedServerRef === null || empty($currentPrivateKeyPath)) {
|
||||||
return ['error' => 'Not connected'];
|
return ['error' => 'Not connected'];
|
||||||
}
|
}
|
||||||
@ -920,34 +1216,113 @@ class SftpManagerTab
|
|||||||
return ['error' => 'SFTP Login failed'];
|
return ['error' => 'SFTP Login failed'];
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $sftp->get($remotePath, $localPath);
|
$queue = [];
|
||||||
if ($result === false) {
|
$files = 0;
|
||||||
return ['error' => 'Download fehlgeschlagen'];
|
$dirs = 0;
|
||||||
|
|
||||||
|
$isDir = (bool) ($remoteRow['isDir'] ?? false);
|
||||||
|
if (!$isDir) {
|
||||||
|
$queue[] = [
|
||||||
|
'remote' => $remotePath,
|
||||||
|
'local' => rtrim($localDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . basename($remotePath),
|
||||||
|
];
|
||||||
|
$files = 1;
|
||||||
|
} else {
|
||||||
|
$rootLocal = rtrim($localDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . basename($remotePath);
|
||||||
|
|
||||||
|
$collect = null;
|
||||||
|
$collect = function (string $srcDir, string $dstDir) use (&$collect, $sftp, &$queue, &$files, &$dirs) {
|
||||||
|
$dirs++;
|
||||||
|
|
||||||
|
$entries = $sftp->nlist($srcDir);
|
||||||
|
if ($entries === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remoteChild = rtrim($srcDir, '/') . '/' . $entry;
|
||||||
|
$localChild = rtrim($dstDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $entry;
|
||||||
|
|
||||||
|
$stat = $sftp->stat($remoteChild);
|
||||||
|
$isDirChild = ($stat['type'] ?? 0) === 2;
|
||||||
|
|
||||||
|
if ($isDirChild) {
|
||||||
|
$collect($remoteChild, $localChild);
|
||||||
|
} else {
|
||||||
|
$queue[] = [
|
||||||
|
'remote' => $remoteChild,
|
||||||
|
'local' => $localChild,
|
||||||
|
];
|
||||||
|
$files++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$collect($remotePath, $rootLocal);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['success' => true];
|
return [
|
||||||
|
'success' => true,
|
||||||
|
'queue' => $queue,
|
||||||
|
'files' => $files,
|
||||||
|
'dirs' => $dirs,
|
||||||
|
'localDir' => $localDir,
|
||||||
|
];
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return ['error' => $e->getMessage()];
|
return ['error' => $e->getMessage()];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
function ($result) use ($sftpTab, $statusLabel, $localDir) {
|
function ($result) use ($sftpTab, $statusLabel, &$currentPrivateKeyPath, $serverListTab) {
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
$statusLabel->setText('Fehler beim Herunterladen: ' . $result['error']);
|
$statusLabel->setText('Fehler beim Vorbereiten des Downloads: ' . $result['error']);
|
||||||
|
$sftpTab->transferInfoLabel->setText('Download fehlgeschlagen');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($result['success'])) {
|
if (!isset($result['success']) || !$result['success']) {
|
||||||
$statusLabel->setText('Datei erfolgreich heruntergeladen');
|
$statusLabel->setText('Unbekannter Fehler beim Vorbereiten des Downloads');
|
||||||
$sftpTab->localFileBrowser->loadDirectory($localDir);
|
$sftpTab->transferInfoLabel->setText('Download fehlgeschlagen');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$queue = $result['queue'] ?? [];
|
||||||
|
$files = (int) ($result['files'] ?? 0);
|
||||||
|
$dirs = (int) ($result['dirs'] ?? 0);
|
||||||
|
|
||||||
|
$sftpTab->pendingDownloadQueue = $queue;
|
||||||
|
$sftpTab->totalDownloadFiles = count($queue);
|
||||||
|
$sftpTab->completedDownloadFiles = 0;
|
||||||
|
|
||||||
|
if ($sftpTab->totalDownloadFiles <= 0) {
|
||||||
|
$sftpTab->transferProgressBar->setValue(1.0);
|
||||||
|
$sftpTab->transferInfoLabel->setText('Keine Dateien zum Herunterladen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sftpTab->transferProgressBar->setValue(0.0);
|
||||||
|
$sftpTab->transferInfoLabel->setText(
|
||||||
|
sprintf(
|
||||||
|
'Download: %d Dateien, %d Ordner',
|
||||||
|
$files,
|
||||||
|
$dirs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
$localDir = $result['localDir'] ?? '';
|
||||||
|
$sftpTab->startNextDownloadTask($currentPrivateKeyPath, $serverListTab, $statusLabel, $localDir);
|
||||||
},
|
},
|
||||||
function ($error) use ($statusLabel) {
|
function ($error) use ($sftpTab, $statusLabel) {
|
||||||
$errorMsg = is_string($error) ? $error : 'Unknown error';
|
$errorMsg = is_string($error) ? $error : 'Unknown error';
|
||||||
$statusLabel->setText('Fehler beim Herunterladen: ' . $errorMsg);
|
$statusLabel->setText('Fehler beim Vorbereiten des Downloads: ' . $errorMsg);
|
||||||
|
$sftpTab->transferInfoLabel->setText('Download fehlgeschlagen');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
$downloadAsyncButton->handleMouseClick(0, 0, 0);
|
$scanButton->handleMouseClick(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createRenameModal(
|
private function createRenameModal(
|
||||||
|
|||||||
@ -4,8 +4,9 @@ namespace PHPNative\Ui\Widget;
|
|||||||
|
|
||||||
class FileBrowser extends Container
|
class FileBrowser extends Container
|
||||||
{
|
{
|
||||||
private VirtualTable $fileTable;
|
private VirtualListView $fileTable;
|
||||||
private Label $pathLabel;
|
private Label $pathLabel;
|
||||||
|
private Label $summaryLabel;
|
||||||
private string $currentPath;
|
private string $currentPath;
|
||||||
private $onFileSelect = null;
|
private $onFileSelect = null;
|
||||||
private $onEditFile = null;
|
private $onEditFile = null;
|
||||||
@ -18,7 +19,7 @@ class FileBrowser extends Container
|
|||||||
public function __construct(string $initialPath = '.', bool $isRemote = false, string $style = '')
|
public function __construct(string $initialPath = '.', bool $isRemote = false, string $style = '')
|
||||||
{
|
{
|
||||||
// Root-Container füllt die verfügbare Höhe im Eltern-Layout (flex-1)
|
// Root-Container füllt die verfügbare Höhe im Eltern-Layout (flex-1)
|
||||||
// und enthält eine VirtualTable für performantes Scrollen.
|
// und enthält eine VirtualListView für performantes Scrollen.
|
||||||
parent::__construct('w-full flex flex-col flex-1 gap-2 ' . $style);
|
parent::__construct('w-full flex flex-col flex-1 gap-2 ' . $style);
|
||||||
|
|
||||||
$this->currentPath = $initialPath;
|
$this->currentPath = $initialPath;
|
||||||
@ -28,10 +29,9 @@ class FileBrowser extends Container
|
|||||||
$this->pathLabel = new Label($initialPath, 'px-3 py-2 bg-gray-200 text-black rounded text-sm font-mono');
|
$this->pathLabel = new Label($initialPath, 'px-3 py-2 bg-gray-200 text-black rounded text-sm font-mono');
|
||||||
$this->addComponent($this->pathLabel);
|
$this->addComponent($this->pathLabel);
|
||||||
|
|
||||||
// VirtualTable nutzt Paging, um bei vielen Einträgen nur
|
// VirtualListView rendert nur die sichtbaren Zeilen und
|
||||||
// einen Teil der Zeilen im UI zu halten – das macht Scrollen
|
// bleibt damit auch bei großen Verzeichnissen flüssig.
|
||||||
// deutlich flüssiger bei großen Verzeichnissen.
|
$this->fileTable = new VirtualListView(' flex-1');
|
||||||
$this->fileTable = new VirtualTable(' flex-1');
|
|
||||||
$this->fileTable->setColumns([
|
$this->fileTable->setColumns([
|
||||||
['key' => 'type', 'title' => 'Typ', 'width' => 60],
|
['key' => 'type', 'title' => 'Typ', 'width' => 60],
|
||||||
['key' => 'name', 'title' => 'Name'],
|
['key' => 'name', 'title' => 'Name'],
|
||||||
@ -42,6 +42,10 @@ class FileBrowser extends Container
|
|||||||
|
|
||||||
$this->addComponent($this->fileTable);
|
$this->addComponent($this->fileTable);
|
||||||
|
|
||||||
|
// Summary label: zeigt Anzahl Ordner/Dateien im aktuellen Verzeichnis
|
||||||
|
$this->summaryLabel = new Label('0 Ordner, 0 Dateien', 'text-xs text-gray-600');
|
||||||
|
$this->addComponent($this->summaryLabel);
|
||||||
|
|
||||||
// Load initial directory (only if local)
|
// Load initial directory (only if local)
|
||||||
if (!$isRemote) {
|
if (!$isRemote) {
|
||||||
$this->loadDirectory($initialPath);
|
$this->loadDirectory($initialPath);
|
||||||
@ -108,6 +112,7 @@ class FileBrowser extends Container
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->fileTable->setData($files);
|
$this->fileTable->setData($files);
|
||||||
|
$this->updateSummaryFromFiles($files);
|
||||||
|
|
||||||
// Handle row selection
|
// Handle row selection
|
||||||
$fileBrowser = $this;
|
$fileBrowser = $this;
|
||||||
@ -216,6 +221,7 @@ class FileBrowser extends Container
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->fileTable->setData($files);
|
$this->fileTable->setData($files);
|
||||||
|
$this->updateSummaryFromFiles($files);
|
||||||
|
|
||||||
// Handle row selection
|
// Handle row selection
|
||||||
$fileBrowser = $this;
|
$fileBrowser = $this;
|
||||||
@ -381,6 +387,9 @@ class FileBrowser extends Container
|
|||||||
|
|
||||||
$this->fileTable->setData($tableData);
|
$this->fileTable->setData($tableData);
|
||||||
|
|
||||||
|
// Update summary from original file list (without '..' Eintrag)
|
||||||
|
$this->updateSummaryFromFiles($files);
|
||||||
|
|
||||||
// Set up row selection handler AFTER data is set (for remote browsers)
|
// Set up row selection handler AFTER data is set (for remote browsers)
|
||||||
// This needs to be done every time because setData might reset handlers
|
// This needs to be done every time because setData might reset handlers
|
||||||
$this->setupRemoteNavigationHandler();
|
$this->setupRemoteNavigationHandler();
|
||||||
@ -410,4 +419,27 @@ class FileBrowser extends Container
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update summary label based on list of files (with isDir flag).
|
||||||
|
*/
|
||||||
|
private function updateSummaryFromFiles(array $files): void
|
||||||
|
{
|
||||||
|
$dirCount = 0;
|
||||||
|
$fileCount = 0;
|
||||||
|
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (!isset($file['isDir'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($file['isDir']) {
|
||||||
|
$dirCount++;
|
||||||
|
} else {
|
||||||
|
$fileCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->summaryLabel->setText(sprintf('%d Ordner, %d Dateien', $dirCount, $fileCount));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/Ui/Widget/ProgressBar.php
Normal file
84
src/Ui/Widget/ProgressBar.php
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace PHPNative\Ui\Widget;
|
||||||
|
|
||||||
|
use PHPNative\Framework\TextRenderer;
|
||||||
|
use PHPNative\Ui\Component;
|
||||||
|
|
||||||
|
class ProgressBar extends Component
|
||||||
|
{
|
||||||
|
private float $value = 0.0; // 0.0 - 1.0
|
||||||
|
|
||||||
|
public function __construct(string $style = '')
|
||||||
|
{
|
||||||
|
// Basis: volle Breite, kleine Höhe
|
||||||
|
$defaultStyle = 'w-full h-[8]';
|
||||||
|
parent::__construct(trim($defaultStyle . ' ' . $style));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setValue(float $value): void
|
||||||
|
{
|
||||||
|
$clamped = max(0.0, min(1.0, $value));
|
||||||
|
if ($this->value === $clamped) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->value = $clamped;
|
||||||
|
$this->markDirty(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getValue(): float
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function layout(null|TextRenderer $textRenderer = null): void
|
||||||
|
{
|
||||||
|
parent::layout($textRenderer);
|
||||||
|
|
||||||
|
// Mindesthöhe sicherstellen
|
||||||
|
if ($this->viewport->height < 4) {
|
||||||
|
$this->viewport->height = 4;
|
||||||
|
$this->contentViewport->height = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renderContent(&$renderer, null|TextRenderer $textRenderer = null): void
|
||||||
|
{
|
||||||
|
if (!$this->visible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$x = (int) $this->contentViewport->x;
|
||||||
|
$y = (int) $this->contentViewport->y;
|
||||||
|
$w = (int) $this->contentViewport->width;
|
||||||
|
$h = (int) $this->contentViewport->height;
|
||||||
|
|
||||||
|
if ($w <= 0 || $h <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hintergrund (Track)
|
||||||
|
sdl_set_render_draw_color($renderer, 220, 220, 220, 255);
|
||||||
|
sdl_render_fill_rect($renderer, [
|
||||||
|
'x' => $x,
|
||||||
|
'y' => $y,
|
||||||
|
'w' => $w,
|
||||||
|
'h' => $h,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fortschrittsbalken
|
||||||
|
$fillWidth = (int) floor($w * $this->value);
|
||||||
|
if ($fillWidth <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sdl_set_render_draw_color($renderer, 59, 130, 246, 255); // Tailwind blue-500
|
||||||
|
sdl_render_fill_rect($renderer, [
|
||||||
|
'x' => $x,
|
||||||
|
'y' => $y,
|
||||||
|
'w' => $fillWidth,
|
||||||
|
'h' => $h,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
351
src/Ui/Widget/VirtualListView.php
Normal file
351
src/Ui/Widget/VirtualListView.php
Normal file
@ -0,0 +1,351 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace PHPNative\Ui\Widget;
|
||||||
|
|
||||||
|
use PHPNative\Framework\TextRenderer;
|
||||||
|
use PHPNative\Ui\Viewport;
|
||||||
|
|
||||||
|
class VirtualListView extends Container
|
||||||
|
{
|
||||||
|
private array $columns = [];
|
||||||
|
private array $rows = [];
|
||||||
|
private float $rowHeight = 0.0;
|
||||||
|
private float $scrollY = 0.0;
|
||||||
|
private int $firstVisibleRow = 0;
|
||||||
|
private int $lastVisibleRow = -1;
|
||||||
|
private ?int $selectedRowIndex = null;
|
||||||
|
private $onRowSelect = null;
|
||||||
|
|
||||||
|
private const SCROLLBAR_WIDTH = 16;
|
||||||
|
private const SCROLLBAR_MIN_SIZE = 20;
|
||||||
|
private const VISIBLE_BUFFER = 5;
|
||||||
|
|
||||||
|
public function __construct(string $style = '')
|
||||||
|
{
|
||||||
|
parent::__construct('w-full ' . $style);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setColumns(array $columns): void
|
||||||
|
{
|
||||||
|
$this->columns = $columns;
|
||||||
|
$this->markDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setData(array $rows): void
|
||||||
|
{
|
||||||
|
$this->rows = array_values($rows);
|
||||||
|
$this->scrollY = 0.0;
|
||||||
|
$this->firstVisibleRow = 0;
|
||||||
|
$this->lastVisibleRow = -1;
|
||||||
|
$this->selectedRowIndex = null;
|
||||||
|
$this->markDirty(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOnRowSelect(callable $callback): void
|
||||||
|
{
|
||||||
|
$this->onRowSelect = $callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSelectedRow(): ?array
|
||||||
|
{
|
||||||
|
if ($this->selectedRowIndex === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->rows[$this->selectedRowIndex] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function layout(null|TextRenderer $textRenderer = null): void
|
||||||
|
{
|
||||||
|
parent::layout($textRenderer);
|
||||||
|
|
||||||
|
if (empty($this->rows) || $textRenderer === null || !$textRenderer->isInitialized()) {
|
||||||
|
$this->clearChildren();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->rowHeight <= 0) {
|
||||||
|
$this->rowHeight = $this->measureRowHeight($textRenderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxScroll = $this->getMaxScroll();
|
||||||
|
$this->scrollY = max(0.0, min($this->scrollY, $maxScroll));
|
||||||
|
|
||||||
|
$this->updateVisibleRows($textRenderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function measureRowHeight(TextRenderer $textRenderer): float
|
||||||
|
{
|
||||||
|
if (empty($this->rows)) {
|
||||||
|
return 24.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$testRow = $this->buildRowContainer($this->rows[0], 0);
|
||||||
|
|
||||||
|
$viewport = new Viewport(
|
||||||
|
x: $this->contentViewport->x,
|
||||||
|
y: $this->contentViewport->y,
|
||||||
|
width: $this->contentViewport->width,
|
||||||
|
height: $this->contentViewport->height,
|
||||||
|
windowWidth: $this->contentViewport->windowWidth,
|
||||||
|
windowHeight: $this->contentViewport->windowHeight,
|
||||||
|
uiScale: $this->contentViewport->uiScale,
|
||||||
|
);
|
||||||
|
$testRow->setViewport($viewport);
|
||||||
|
$testRow->setContentViewport(clone $viewport);
|
||||||
|
$testRow->layout($textRenderer);
|
||||||
|
|
||||||
|
$rowViewport = $testRow->getViewport();
|
||||||
|
$height = max(1.0, (float) $rowViewport->height);
|
||||||
|
|
||||||
|
return $height;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateVisibleRows(?TextRenderer $textRenderer): void
|
||||||
|
{
|
||||||
|
$this->clearChildren();
|
||||||
|
|
||||||
|
if ($this->rowHeight <= 0 || empty($this->rows) || $textRenderer === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$viewportHeight = $this->contentViewport->height;
|
||||||
|
if ($viewportHeight <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowCount = count($this->rows);
|
||||||
|
$first = (int) floor($this->scrollY / $this->rowHeight);
|
||||||
|
$visibleCount = (int) ceil($viewportHeight / $this->rowHeight) + self::VISIBLE_BUFFER;
|
||||||
|
$last = min($rowCount - 1, $first + $visibleCount - 1);
|
||||||
|
|
||||||
|
$this->firstVisibleRow = $first;
|
||||||
|
$this->lastVisibleRow = $last;
|
||||||
|
|
||||||
|
for ($i = $first; $i <= $last; $i++) {
|
||||||
|
$rowData = $this->rows[$i];
|
||||||
|
$rowContainer = $this->buildRowContainer($rowData, $i);
|
||||||
|
|
||||||
|
$rowY = $this->contentViewport->y + ($i * $this->rowHeight) - $this->scrollY;
|
||||||
|
|
||||||
|
$rowViewport = new Viewport(
|
||||||
|
x: $this->contentViewport->x,
|
||||||
|
y: (int) $rowY,
|
||||||
|
width: $this->contentViewport->width,
|
||||||
|
height: $this->rowHeight,
|
||||||
|
windowWidth: $this->contentViewport->windowWidth,
|
||||||
|
windowHeight: $this->contentViewport->windowHeight,
|
||||||
|
uiScale: $this->contentViewport->uiScale,
|
||||||
|
);
|
||||||
|
|
||||||
|
$rowContainer->setViewport($rowViewport);
|
||||||
|
$rowContainer->setContentViewport(clone $rowViewport);
|
||||||
|
$rowContainer->layout($textRenderer);
|
||||||
|
|
||||||
|
$this->addComponent($rowContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildRowContainer(array $rowData, int $rowIndex): Container
|
||||||
|
{
|
||||||
|
$isSelected = $this->selectedRowIndex !== null && $rowIndex === $this->selectedRowIndex;
|
||||||
|
$rowStyle = 'flex flex-row border-b border-gray-200 hover:bg-gray-400';
|
||||||
|
if ($isSelected) {
|
||||||
|
$rowStyle .= ' bg-blue-100';
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowContainer = new VirtualListRow($this, $rowIndex, $rowStyle);
|
||||||
|
|
||||||
|
foreach ($this->columns as $column) {
|
||||||
|
$key = $column['key'];
|
||||||
|
$value = $rowData[$key] ?? '';
|
||||||
|
$width = $column['width'] ?? null;
|
||||||
|
|
||||||
|
$cellStyle = 'px-4 py-2 text-black border-r border-gray-300';
|
||||||
|
if ($width) {
|
||||||
|
$cellStyle .= ' w-' . ((int) ($width / 4));
|
||||||
|
} else {
|
||||||
|
$cellStyle .= ' flex-1';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($column['render']) && is_callable($column['render'])) {
|
||||||
|
$cellContent = $column['render']($rowData, $rowIndex);
|
||||||
|
$cellContainer = new Container($cellStyle);
|
||||||
|
$cellContainer->addComponent($cellContent);
|
||||||
|
$rowContainer->addComponent($cellContainer);
|
||||||
|
} else {
|
||||||
|
$cellLabel = new Label((string) $value, $cellStyle);
|
||||||
|
$rowContainer->addComponent($cellLabel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rowContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMaxScroll(): float
|
||||||
|
{
|
||||||
|
if ($this->rowHeight <= 0) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalHeight = count($this->rows) * $this->rowHeight;
|
||||||
|
$viewportHeight = $this->contentViewport->height;
|
||||||
|
|
||||||
|
return max(0.0, $totalHeight - $viewportHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handleMouseWheel(float $mouseX, float $mouseY, float $deltaY): bool
|
||||||
|
{
|
||||||
|
if (!$this->visible) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$mouseX < $this->contentViewport->x ||
|
||||||
|
$mouseX > ($this->contentViewport->x + $this->contentViewport->width) ||
|
||||||
|
$mouseY < $this->contentViewport->y ||
|
||||||
|
$mouseY > ($this->contentViewport->y + $this->contentViewport->height)
|
||||||
|
) {
|
||||||
|
return parent::handleMouseWheel($mouseX, $mouseY, $deltaY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxScroll = $this->getMaxScroll();
|
||||||
|
if ($maxScroll <= 0.0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scrollStep = max($this->rowHeight * 3, 20.0);
|
||||||
|
$oldScroll = $this->scrollY;
|
||||||
|
|
||||||
|
$this->scrollY = max(0.0, min($maxScroll, $this->scrollY + ($deltaY * $scrollStep)));
|
||||||
|
|
||||||
|
if ($this->scrollY === $oldScroll) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->markDirty(true, false);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renderContent(&$renderer, null|TextRenderer $textRenderer = null): void
|
||||||
|
{
|
||||||
|
if (!$this->visible || $textRenderer === null || !$textRenderer->isInitialized()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$clipRect = [
|
||||||
|
'x' => (int) $this->contentViewport->x,
|
||||||
|
'y' => (int) $this->contentViewport->y,
|
||||||
|
'w' => (int) $this->contentViewport->width,
|
||||||
|
'h' => (int) $this->contentViewport->height,
|
||||||
|
];
|
||||||
|
|
||||||
|
sdl_set_render_clip_rect($renderer, $clipRect);
|
||||||
|
|
||||||
|
foreach ($this->children as $child) {
|
||||||
|
$child->render($renderer, $textRenderer);
|
||||||
|
$child->renderContent($renderer, $textRenderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
sdl_set_render_clip_rect($renderer, null);
|
||||||
|
|
||||||
|
$this->renderScrollbar($renderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderScrollbar(&$renderer): void
|
||||||
|
{
|
||||||
|
if ($this->rowHeight <= 0 || empty($this->rows)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalHeight = count($this->rows) * $this->rowHeight;
|
||||||
|
$viewportHeight = $this->contentViewport->height;
|
||||||
|
|
||||||
|
if ($totalHeight <= $viewportHeight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scrollbarHeight = $viewportHeight;
|
||||||
|
$thumbHeight = max(
|
||||||
|
self::SCROLLBAR_MIN_SIZE,
|
||||||
|
($viewportHeight / $totalHeight) * $scrollbarHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
$maxScroll = $this->getMaxScroll();
|
||||||
|
if ($maxScroll <= 0.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$thumbRange = $scrollbarHeight - $thumbHeight;
|
||||||
|
$scrollRatio = $this->scrollY / $maxScroll;
|
||||||
|
$thumbY = $this->contentViewport->y + ($scrollRatio * $thumbRange);
|
||||||
|
|
||||||
|
$scrollbarX = ($this->contentViewport->x + $this->contentViewport->width) - self::SCROLLBAR_WIDTH;
|
||||||
|
|
||||||
|
sdl_set_render_draw_color($renderer, 220, 220, 220, 255);
|
||||||
|
sdl_render_fill_rect($renderer, [
|
||||||
|
'x' => (int) $scrollbarX,
|
||||||
|
'y' => (int) $this->contentViewport->y,
|
||||||
|
'w' => (int) self::SCROLLBAR_WIDTH,
|
||||||
|
'h' => (int) $scrollbarHeight,
|
||||||
|
]);
|
||||||
|
|
||||||
|
sdl_set_render_draw_color($renderer, 120, 120, 120, 230);
|
||||||
|
sdl_render_fill_rect($renderer, [
|
||||||
|
'x' => (int) ($scrollbarX + 2),
|
||||||
|
'y' => (int) $thumbY,
|
||||||
|
'w' => (int) (self::SCROLLBAR_WIDTH - 4),
|
||||||
|
'h' => (int) $thumbHeight,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectRow(int $rowIndex): void
|
||||||
|
{
|
||||||
|
if ($rowIndex < 0 || $rowIndex >= count($this->rows)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->selectedRowIndex = $rowIndex;
|
||||||
|
|
||||||
|
if ($this->onRowSelect !== null) {
|
||||||
|
($this->onRowSelect)($rowIndex, $this->rows[$rowIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->markDirty(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VirtualListRow extends Container
|
||||||
|
{
|
||||||
|
private int $rowIndex;
|
||||||
|
private VirtualListView $table;
|
||||||
|
|
||||||
|
public function __construct(VirtualListView $table, int $rowIndex, string $style = '')
|
||||||
|
{
|
||||||
|
$this->rowIndex = $rowIndex;
|
||||||
|
$this->table = $table;
|
||||||
|
parent::__construct($style);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handleMouseClick(float $mouseX, float $mouseY, int $button): bool
|
||||||
|
{
|
||||||
|
$handled = parent::handleMouseClick($mouseX, $mouseY, $button);
|
||||||
|
if ($handled) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$mouseX >= $this->viewport->x &&
|
||||||
|
$mouseX <= ($this->viewport->x + $this->viewport->width) &&
|
||||||
|
$mouseY >= $this->viewport->y &&
|
||||||
|
$mouseY <= ($this->viewport->y + $this->viewport->height)
|
||||||
|
) {
|
||||||
|
$this->table->selectRow($this->rowIndex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user