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\TextArea;
|
||||
use PHPNative\Ui\Widget\TextInput;
|
||||
use PHPNative\Ui\Widget\ProgressBar;
|
||||
|
||||
class SftpManagerTab
|
||||
{
|
||||
@ -33,6 +34,14 @@ class SftpManagerTab
|
||||
private null|array $currentRemoteSelection = null;
|
||||
private ?string $lastRemoteClickPath = null;
|
||||
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(
|
||||
string &$apiKey,
|
||||
@ -88,19 +97,26 @@ class SftpManagerTab
|
||||
$remoteBrowserContainer->addComponent($this->connectionStatusLabel);
|
||||
|
||||
// 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(
|
||||
'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(
|
||||
'← 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($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($transferContainer);
|
||||
$this->tab->addComponent($remoteBrowserContainer);
|
||||
@ -776,16 +792,6 @@ class SftpManagerTab
|
||||
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) {
|
||||
$statusLabel->setText('Kein Server ausgewählt');
|
||||
return;
|
||||
@ -801,13 +807,177 @@ class SftpManagerTab
|
||||
$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;
|
||||
$sftpTab = $this;
|
||||
|
||||
$uploadAsyncButton = new Button('Upload', '');
|
||||
$uploadAsyncButton = new Button('UploadChunk', '');
|
||||
$uploadAsyncButton->setOnClickAsync(
|
||||
function () use (&$currentPrivateKeyPath, &$selectedServerRef, $localPath, $remotePath) {
|
||||
if ($selectedServerRef === null || empty($currentPrivateKeyPath)) {
|
||||
@ -824,7 +994,23 @@ class SftpManagerTab
|
||||
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) {
|
||||
return ['error' => 'Upload fehlgeschlagen'];
|
||||
}
|
||||
@ -834,19 +1020,34 @@ class SftpManagerTab
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
},
|
||||
function ($result) use ($sftpTab, $statusLabel, &$currentPrivateKeyPath, &$selectedServerRef) {
|
||||
function ($result) use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) {
|
||||
if (isset($result['error'])) {
|
||||
$sftpTab->transferInfoLabel->setText('Upload fehlgeschlagen: ' . $result['error']);
|
||||
$statusLabel->setText('Fehler beim Hochladen: ' . $result['error']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isset($result['success'])) {
|
||||
$statusLabel->setText('Datei erfolgreich hochgeladen');
|
||||
$sftpTab->reloadCurrentDirectory($currentPrivateKeyPath, $selectedServerRef, $statusLabel);
|
||||
$sftpTab->completedUploadFiles++;
|
||||
$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';
|
||||
$sftpTab->transferInfoLabel->setText('Upload fehlgeschlagen: ' . $errorMsg);
|
||||
$statusLabel->setText('Fehler beim Hochladen: ' . $errorMsg);
|
||||
},
|
||||
);
|
||||
@ -854,6 +1055,105 @@ class SftpManagerTab
|
||||
$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(
|
||||
string &$currentPrivateKeyPath,
|
||||
ServerListTab $serverListTab,
|
||||
@ -872,11 +1172,6 @@ class SftpManagerTab
|
||||
return;
|
||||
}
|
||||
|
||||
if (($remoteRow['isDir'] ?? false) === true) {
|
||||
$statusLabel->setText('Ordner-Download wird noch nicht unterstützt');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($serverListTab->selectedServer === null) {
|
||||
$statusLabel->setText('Kein Server ausgewählt');
|
||||
return;
|
||||
@ -897,15 +1192,16 @@ class SftpManagerTab
|
||||
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;
|
||||
$sftpTab = $this;
|
||||
|
||||
$downloadAsyncButton = new Button('Download', '');
|
||||
$downloadAsyncButton->setOnClickAsync(
|
||||
function () use (&$currentPrivateKeyPath, &$selectedServerRef, $remotePath, $localPath) {
|
||||
$scanButton = new Button('ScanDownload', '');
|
||||
$scanButton->setOnClickAsync(
|
||||
function () use (&$currentPrivateKeyPath, &$selectedServerRef, $remotePath, $remoteRow, $localDir) {
|
||||
if ($selectedServerRef === null || empty($currentPrivateKeyPath)) {
|
||||
return ['error' => 'Not connected'];
|
||||
}
|
||||
@ -920,34 +1216,113 @@ class SftpManagerTab
|
||||
return ['error' => 'SFTP Login failed'];
|
||||
}
|
||||
|
||||
$result = $sftp->get($remotePath, $localPath);
|
||||
if ($result === false) {
|
||||
return ['error' => 'Download fehlgeschlagen'];
|
||||
$queue = [];
|
||||
$files = 0;
|
||||
$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;
|
||||
}
|
||||
|
||||
return ['success' => true];
|
||||
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,
|
||||
'queue' => $queue,
|
||||
'files' => $files,
|
||||
'dirs' => $dirs,
|
||||
'localDir' => $localDir,
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return ['error' => $e->getMessage()];
|
||||
}
|
||||
},
|
||||
function ($result) use ($sftpTab, $statusLabel, $localDir) {
|
||||
function ($result) use ($sftpTab, $statusLabel, &$currentPrivateKeyPath, $serverListTab) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (isset($result['success'])) {
|
||||
$statusLabel->setText('Datei erfolgreich heruntergeladen');
|
||||
$sftpTab->localFileBrowser->loadDirectory($localDir);
|
||||
if (!isset($result['success']) || !$result['success']) {
|
||||
$statusLabel->setText('Unbekannter Fehler beim Vorbereiten des Downloads');
|
||||
$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';
|
||||
$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(
|
||||
|
||||
@ -4,8 +4,9 @@ namespace PHPNative\Ui\Widget;
|
||||
|
||||
class FileBrowser extends Container
|
||||
{
|
||||
private VirtualTable $fileTable;
|
||||
private VirtualListView $fileTable;
|
||||
private Label $pathLabel;
|
||||
private Label $summaryLabel;
|
||||
private string $currentPath;
|
||||
private $onFileSelect = null;
|
||||
private $onEditFile = null;
|
||||
@ -18,7 +19,7 @@ class FileBrowser extends Container
|
||||
public function __construct(string $initialPath = '.', bool $isRemote = false, string $style = '')
|
||||
{
|
||||
// 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);
|
||||
|
||||
$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->addComponent($this->pathLabel);
|
||||
|
||||
// VirtualTable nutzt Paging, um bei vielen Einträgen nur
|
||||
// einen Teil der Zeilen im UI zu halten – das macht Scrollen
|
||||
// deutlich flüssiger bei großen Verzeichnissen.
|
||||
$this->fileTable = new VirtualTable(' flex-1');
|
||||
// VirtualListView rendert nur die sichtbaren Zeilen und
|
||||
// bleibt damit auch bei großen Verzeichnissen flüssig.
|
||||
$this->fileTable = new VirtualListView(' flex-1');
|
||||
$this->fileTable->setColumns([
|
||||
['key' => 'type', 'title' => 'Typ', 'width' => 60],
|
||||
['key' => 'name', 'title' => 'Name'],
|
||||
@ -42,6 +42,10 @@ class FileBrowser extends Container
|
||||
|
||||
$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)
|
||||
if (!$isRemote) {
|
||||
$this->loadDirectory($initialPath);
|
||||
@ -108,6 +112,7 @@ class FileBrowser extends Container
|
||||
}
|
||||
|
||||
$this->fileTable->setData($files);
|
||||
$this->updateSummaryFromFiles($files);
|
||||
|
||||
// Handle row selection
|
||||
$fileBrowser = $this;
|
||||
@ -216,6 +221,7 @@ class FileBrowser extends Container
|
||||
}
|
||||
|
||||
$this->fileTable->setData($files);
|
||||
$this->updateSummaryFromFiles($files);
|
||||
|
||||
// Handle row selection
|
||||
$fileBrowser = $this;
|
||||
@ -381,6 +387,9 @@ class FileBrowser extends Container
|
||||
|
||||
$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)
|
||||
// This needs to be done every time because setData might reset handlers
|
||||
$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