tab = new Container('flex flex-row p-4 gap-4 bg-gray-50'); $currentPrivateKeyPath = &$privateKeyPath; $currentRemoteStartDir = &$remoteStartDir; // Left side: Local file browser // Lokaler Browser: Spalte, Scrollen übernimmt der FileBrowser selbst $localBrowserContainer = new Container('flex flex-col flex-1 gap-2'); $localBrowserContainer->addComponent(new Label('Lokal', 'text-lg font-bold text-black mb-2')); $this->localFileBrowser = new FileBrowser( getcwd(), false, 'flex-1 bg-white border-2 border-gray-300 rounded p-2', ); $localBrowserContainer->addComponent($this->localFileBrowser); // Track local file selection for uploads $sftpTab = $this; $this->localFileBrowser->setOnFileSelect(function ($path, $row) use ($sftpTab) { $sftpTab->currentLocalSelection = [ 'path' => $path, 'row' => $row, ]; }); // Right side: Remote file browser // Remote-Browser: Spalte, Scrollen übernimmt der FileBrowser selbst $remoteBrowserContainer = new Container('flex flex-col flex-1 gap-2'); // Header with title and new file button $remoteHeader = new Container('flex flex-row items-center gap-2 mb-2'); $remoteHeader->addComponent(new Label('Remote', 'text-lg font-bold text-black flex-1')); $newFileButton = new Button('', 'px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 flex items-center justify-center'); $newFileIcon = new Icon(\PHPNative\Tailwind\Data\Icon::plus, 16, 'text-white'); $newFileButton->setIcon($newFileIcon); $remoteHeader->addComponent($newFileButton); $remoteBrowserContainer->addComponent($remoteHeader); $this->remoteFileBrowser = new FileBrowser('/', true, 'flex-1 bg-white border-2 border-gray-300 rounded p-2'); $remoteBrowserContainer->addComponent($this->remoteFileBrowser); // Connection status label $this->connectionStatusLabel = new Label('Nicht verbunden', 'text-sm text-gray-600 italic mb-2'); $remoteBrowserContainer->addComponent($this->connectionStatusLabel); // Middle: Transfer buttons (Upload/Download) $transferContainer = new Container('flex flex-col justify-center items-center gap-2 w-[180]'); $uploadButton = new Button( 'Hochladen →', 'w-full px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700', ); $downloadButton = new Button( '← Herunterladen', '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->transferBytesLabel = new Label('', 'text-xs text-gray-600'); $this->transferProgressBar = new ProgressBar('mt-1'); $this->transferProgressBar->setValue(0.0); $transferContainer->addComponent($this->transferInfoLabel); $transferContainer->addComponent($this->transferBytesLabel); $transferContainer->addComponent($this->transferProgressBar); $this->tab->addComponent($localBrowserContainer); $this->tab->addComponent($transferContainer); $this->tab->addComponent($remoteBrowserContainer); // Setup remote navigation handler $this->setupRemoteNavigationHandler($currentPrivateKeyPath, $serverListTab, $statusLabel); // Setup SFTP connection handler $this->setupSftpConnectionHandler($currentPrivateKeyPath, $currentRemoteStartDir, $serverListTab, $tabContainer, $statusLabel); // Create edit modal $this->createEditModal($currentPrivateKeyPath, $serverListTab, $statusLabel); // Create filename input modal $this->createFilenameModal($currentPrivateKeyPath, $serverListTab, $statusLabel); // Create rename modal $this->createRenameModal($currentPrivateKeyPath, $serverListTab, $statusLabel); // Create delete confirmation modal $this->createDeleteConfirmModal($currentPrivateKeyPath, $serverListTab, $statusLabel); // Setup transfer button handlers $uploadButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { $sftpTab->handleUpload($currentPrivateKeyPath, $serverListTab, $statusLabel); }); $downloadButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { $sftpTab->handleDownload($currentPrivateKeyPath, $serverListTab, $statusLabel); }); // Setup file edit handler $this->remoteFileBrowser->setOnEditFile(function ($path, $row) use ( &$currentPrivateKeyPath, $serverListTab, $statusLabel, ) { $this->handleFileEdit($path, $row, $currentPrivateKeyPath, $serverListTab, $statusLabel); }); // Setup new file button handler $sftpTab = $this; $newFileButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { $sftpTab->handleNewFile($currentPrivateKeyPath, $serverListTab, $statusLabel); }); // Setup rename handler $this->remoteFileBrowser->setOnRenameFile(function ($path, $row) use ( $sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel, ) { $sftpTab->handleFileRename($path, $row, $currentPrivateKeyPath, $serverListTab, $statusLabel); }); // Setup delete handler $this->remoteFileBrowser->setOnDeleteFile(function ($path, $row) use ( $sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel, ) { $sftpTab->handleFileDelete($path, $row, $currentPrivateKeyPath, $serverListTab, $statusLabel); }); } private function setupRemoteNavigationHandler( string &$currentPrivateKeyPath, ServerListTab $serverListTab, Label $statusLabel, ): void { $sftpTab = $this; // Create reference to selectedServer array outside of closure $selectedServerRef = &$serverListTab->selectedServer; $this->remoteFileBrowser->setOnFileSelect( function ($path, $row) use ( $sftpTab, &$currentPrivateKeyPath, &$selectedServerRef, $statusLabel, ) { // Track remote selection (for downloads) $sftpTab->currentRemoteSelection = [ 'path' => $path, 'row' => $row, ]; // Only navigate when a directory is selected if (!isset($row['isDir']) || !$row['isDir']) { return; } // Require double click for navigation: // first click selektiert nur, zweiter Klick (innerhalb kurzer Zeit) // öffnet das Verzeichnis. $now = microtime(true); $doubleClickThreshold = 0.4; // Sekunden if ( $sftpTab->lastRemoteClickPath !== $path || ($now - $sftpTab->lastRemoteClickTime) > $doubleClickThreshold ) { $sftpTab->lastRemoteClickPath = $path; $sftpTab->lastRemoteClickTime = $now; return; } // Zweiter Klick innerhalb des Zeitfensters -> als Doppelklick werten $sftpTab->lastRemoteClickPath = null; $sftpTab->lastRemoteClickTime = 0.0; $loadButton = new Button('Load', ''); $loadButton->setOnClickAsync( function () use ($path, &$currentPrivateKeyPath, &$selectedServerRef) { if ($selectedServerRef === null || empty($currentPrivateKeyPath)) { return ['error' => 'Not connected']; } // Copy to local variable for async context $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']; } $files = $sftp->nlist($path); if ($files === false) { return ['error' => 'Cannot read directory']; } $fileList = []; foreach ($files as $file) { if ($file === '.' || $file === '..') { continue; } $fullPath = rtrim($path, '/') . '/' . $file; $stat = $sftp->stat($fullPath); $fileList[] = [ 'name' => $file, 'path' => $fullPath, 'isDir' => ($stat['type'] ?? 0) === 2, 'size' => $stat['size'] ?? 0, 'mtime' => $stat['mtime'] ?? 0, ]; } return ['success' => true, 'path' => $path, 'files' => $fileList]; } catch (\Exception $e) { return ['error' => $e->getMessage()]; } }, function ($result) use ($sftpTab, $statusLabel) { if (isset($result['error'])) { $statusLabel->setText('SFTP Fehler: ' . $result['error']); return; } if (isset($result['success'])) { $sftpTab->remoteFileBrowser->setPath($result['path']); $sftpTab->remoteFileBrowser->setFileData($result['files']); } }, function ($error) use ($statusLabel) { $errorMsg = is_string($error) ? $error : 'Unknown error'; $statusLabel->setText('SFTP Error: ' . $errorMsg); }, ); $loadButton->handleMouseClick(0, 0, 0); }, ); } private function setupSftpConnectionHandler( string &$currentPrivateKeyPath, string &$currentRemoteStartDir, ServerListTab $serverListTab, \PHPNative\Ui\Widget\TabContainer $tabContainer, Label $statusLabel, ): void { $sftpTab = $this; // Create a reference to selectedServer that we can update $selectedServerRef = &$serverListTab->selectedServer; // Set up async handler for SFTP connection (tab switch happens in success callback) $serverListTab->getSftpButton()->setOnClickAsync( function () use (&$currentPrivateKeyPath, &$currentRemoteStartDir, &$selectedServerRef) { if ($selectedServerRef === null) { return ['error' => 'Kein Server ausgewählt']; } // Copy the selected server data to a local variable for use in async context $selectedServer = $selectedServerRef; if (empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { return ['error' => 'Private Key Pfad nicht konfiguriert oder Datei nicht gefunden']; } try { $ssh = new \phpseclib3\Net\SSH2($selectedServer['ipv4']); $key = \phpseclib3\Crypt\PublicKeyLoader::load(file_get_contents($currentPrivateKeyPath)); if (!$ssh->login('root', $key)) { return ['error' => 'SSH Login fehlgeschlagen']; } $sftp = new \phpseclib3\Net\SFTP($selectedServer['ipv4']); if (!$sftp->login('root', $key)) { return ['error' => 'SFTP Login fehlgeschlagen']; } // Use configured start directory or fallback to root $startDir = !empty($currentRemoteStartDir) ? $currentRemoteStartDir : '/'; // Check if start directory exists, fallback to root if not if (!$sftp->is_dir($startDir)) { $startDir = '/'; } $files = $sftp->nlist($startDir); if ($files === false) { return ['error' => 'Kann Verzeichnis nicht lesen: ' . $startDir]; } $fileList = []; foreach ($files as $file) { if ($file === '.' || $file === '..') { continue; } $fullPath = rtrim($startDir, '/') . '/' . $file; $stat = $sftp->stat($fullPath); $fileList[] = [ 'name' => $file, 'path' => $fullPath, 'isDir' => ($stat['type'] ?? 0) === 2, 'size' => $stat['size'] ?? 0, 'mtime' => $stat['mtime'] ?? 0, ]; } return [ 'success' => true, 'server' => $selectedServer, 'files' => $fileList, 'path' => $startDir, ]; } catch (\Exception $e) { return ['error' => 'Verbindung fehlgeschlagen: ' . $e->getMessage()]; } }, function ($result) use ($sftpTab, $tabContainer, $statusLabel) { if (isset($result['error'])) { $statusLabel->setText('SFTP Fehler: ' . $result['error']); return; } if (isset($result['success']) && $result['success']) { // Switch to SFTP tab on successful connection // Tab-Reihenfolge in App.php: // 0 = Server, 1 = Kanban, 2 = SFTP Manager $tabContainer->setActiveTab(2); $sftpTab->connectionStatusLabel->setText( 'Verbunden mit: ' . $result['server']['name'] . ' (' . $result['server']['ipv4'] . ')', ); $statusLabel->setText('SFTP Verbindung erfolgreich zu ' . $result['server']['name']); $sftpTab->remoteFileBrowser->setPath($result['path'] ?? '/'); $sftpTab->remoteFileBrowser->setFileData($result['files']); } }, function ($error) use ($statusLabel) { $errorMsg = is_string($error) ? $error : ( is_object($error) && method_exists($error, 'getMessage') ? $error->getMessage() : 'Unbekannter Fehler' ); $statusLabel->setText('SFTP Async Fehler: ' . $errorMsg); }, ); } public function getContainer(): Container { return $this->tab; } public function getEditModal(): Modal { return $this->editModal; } public function getFilenameModal(): Modal { return $this->filenameModal; } public function getRenameModal(): Modal { return $this->renameModal; } public function getDeleteConfirmModal(): Modal { return $this->deleteConfirmModal; } private function createEditModal( string &$currentPrivateKeyPath, ServerListTab $serverListTab, Label $statusLabel, ): void { // Create modal content container $modalContent = new Container('flex flex-col bg-white rounded-lg shadow-lg p-4 gap-3 w-[600] h-[400]'); // Disable texture caching for modal content to allow dynamic content updates $modalContent->setUseTextureCache(false); // File path label $this->filePathLabel = new Label('', 'text-sm text-gray-600 font-mono'); $modalContent->addComponent($this->filePathLabel); // Text editor $this->fileEditor = new TextArea( '', 'Lade Datei...', 'flex-1 border-2 border-gray-300 rounded p-2 bg-white text-black font-mono text-sm', ); // Disable texture caching for TextArea to ensure text updates are visible $this->fileEditor->setUseTextureCache(false); $modalContent->addComponent($this->fileEditor); // Button container $buttonContainer = new Container('flex flex-row justify-end gap-2'); // Cancel button $cancelButton = new Button( 'Abbrechen', 'px-4 py-2 bg-gray-300 text-black rounded hover:bg-gray-400', ); $sftpTab = $this; $cancelButton->setOnClick(function () use ($sftpTab) { $sftpTab->editModal->setVisible(false); }); $buttonContainer->addComponent($cancelButton); // Save button $saveButton = new Button('Speichern', 'px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600'); $saveButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { $sftpTab->handleFileSave($currentPrivateKeyPath, $serverListTab, $statusLabel); }); $buttonContainer->addComponent($saveButton); $modalContent->addComponent($buttonContainer); // Create modal (will be added to main container by App.php) $this->editModal = new Modal($modalContent); } private function handleFileEdit( string $path, array $row, string &$currentPrivateKeyPath, ServerListTab $serverListTab, Label $statusLabel, ): void { $this->currentEditFilePath = $path; $this->filePathLabel->setText('Bearbeite: ' . $path); $this->fileEditor->setValue(''); $this->fileEditor->setFocused(false); $this->editModal->setVisible(true); // Reference to components for async callback $selectedServerRef = &$serverListTab->selectedServer; $sftpTab = $this; // Load file content asynchronously $loadButton = new Button('Load', ''); $loadButton->setOnClickAsync( function () use ($path, &$currentPrivateKeyPath, &$selectedServerRef) { 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']; } $fileContent = $sftp->get($path); if ($fileContent === false) { return ['error' => 'Could not read file']; } return ['success' => true, 'content' => $fileContent]; } catch (\Exception $e) { return ['error' => $e->getMessage()]; } }, function ($result) use ($sftpTab, $statusLabel) { if (isset($result['error'])) { $statusLabel->setText('Fehler beim Laden: ' . $result['error']); $sftpTab->editModal->setVisible(false); return; } if (isset($result['success'])) { $sftpTab->fileEditor->setValue($result['content']); $sftpTab->fileEditor->setFocused(true); $statusLabel->setText('Datei geladen'); } }, function ($error) use ($statusLabel, $sftpTab) { $errorMsg = is_string($error) ? $error : 'Unknown error'; $statusLabel->setText('Fehler: ' . $errorMsg); $sftpTab->editModal->setVisible(false); }, ); $loadButton->handleMouseClick(0, 0, 0); } private function handleFileSave( string &$currentPrivateKeyPath, ServerListTab $serverListTab, Label $statusLabel, ): void { $content = $this->fileEditor->getValue(); $path = $this->currentEditFilePath; if (empty($path)) { $statusLabel->setText('Fehler: Kein Dateipfad'); return; } // Reference to components for async callback $selectedServerRef = &$serverListTab->selectedServer; $sftpTab = $this; // Save file content asynchronously $saveButton = new Button('Save', ''); $saveButton->setOnClickAsync( function () use ($path, $content, &$currentPrivateKeyPath, &$selectedServerRef) { 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']; } $result = $sftp->put($path, $content); if ($result === false) { return ['error' => 'Could not write file']; } return ['success' => true]; } catch (\Exception $e) { return ['error' => $e->getMessage()]; } }, function ($result) use ($sftpTab, $statusLabel, &$currentPrivateKeyPath, &$selectedServerRef) { if (isset($result['error'])) { $statusLabel->setText('Fehler beim Speichern: ' . $result['error']); return; } if (isset($result['success'])) { $statusLabel->setText('Datei erfolgreich gespeichert'); $sftpTab->editModal->setVisible(false); // Reload the current directory to show the new/updated file $sftpTab->reloadCurrentDirectory($currentPrivateKeyPath, $selectedServerRef, $statusLabel); } }, function ($error) use ($statusLabel) { $errorMsg = is_string($error) ? $error : 'Unknown error'; $statusLabel->setText('Fehler: ' . $errorMsg); }, ); $saveButton->handleMouseClick(0, 0, 0); } private function handleNewFile( string &$currentPrivateKeyPath, ServerListTab $serverListTab, Label $statusLabel, ): void { if ($serverListTab->selectedServer === null) { $statusLabel->setText('Kein Server ausgewählt'); return; } // Show filename input modal $this->filenameInput->setValue('neue_datei.txt'); $this->filenameModal->setVisible(true); } private function createFilenameModal( string &$currentPrivateKeyPath, ServerListTab $serverListTab, Label $statusLabel, ): void { // Create modal content $modalContent = new Container('flex flex-col bg-white rounded-lg shadow-lg p-4 gap-3 w-96'); // Title $modalContent->addComponent(new Label('Neue Datei', 'text-lg font-bold text-black')); // Filename input $this->filenameInput = new TextInput( 'Dateiname', 'w-full border-2 border-gray-300 rounded px-3 py-2 bg-white text-black', ); $modalContent->addComponent($this->filenameInput); // Button container $buttonContainer = new Container('flex flex-row justify-end gap-2'); // Cancel button $cancelButton = new Button('Abbrechen', 'px-4 py-2 bg-gray-300 text-black rounded hover:bg-gray-400'); $sftpTab = $this; $cancelButton->setOnClick(function () use ($sftpTab) { $sftpTab->filenameModal->setVisible(false); }); $buttonContainer->addComponent($cancelButton); // Create button $createButton = new Button('Erstellen', 'px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700'); $createButton->setOnClick(function () use ($sftpTab, $statusLabel) { $filename = trim($sftpTab->filenameInput->getValue()); if (empty($filename)) { $statusLabel->setText('Dateiname darf nicht leer sein'); return; } // Close filename modal $sftpTab->filenameModal->setVisible(false); // Create the new file with the given name $currentPath = $sftpTab->remoteFileBrowser->getCurrentPath(); $newFilePath = rtrim($currentPath, '/') . '/' . $filename; $sftpTab->currentEditFilePath = $newFilePath; $sftpTab->filePathLabel->setText('Neue Datei: ' . $newFilePath); $sftpTab->fileEditor->setValue(''); $sftpTab->fileEditor->setFocused(true); $sftpTab->editModal->setVisible(true); $statusLabel->setText('Neue Datei wird erstellt: ' . $filename); }); $buttonContainer->addComponent($createButton); $modalContent->addComponent($buttonContainer); // Create modal $this->filenameModal = new Modal($modalContent); } private function reloadCurrentDirectory( string &$currentPrivateKeyPath, ?array &$selectedServerRef, Label $statusLabel, ): void { $path = $this->remoteFileBrowser->getCurrentPath(); if ($selectedServerRef === null || empty($currentPrivateKeyPath)) { return; } $sftpTab = $this; // Load directory asynchronously $loadButton = new Button('Load', ''); $loadButton->setOnClickAsync( function () use ($path, &$currentPrivateKeyPath, &$selectedServerRef) { 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']; } $files = $sftp->nlist($path); if ($files === false) { return ['error' => 'Cannot read directory']; } $fileList = []; foreach ($files as $file) { if ($file === '.' || $file === '..') { continue; } $fullPath = rtrim($path, '/') . '/' . $file; $stat = $sftp->stat($fullPath); $fileList[] = [ 'name' => $file, 'path' => $fullPath, 'isDir' => ($stat['type'] ?? 0) === 2, 'size' => $stat['size'] ?? 0, 'mtime' => $stat['mtime'] ?? 0, ]; } return ['success' => true, 'path' => $path, 'files' => $fileList]; } catch (\Exception $e) { return ['error' => $e->getMessage()]; } }, function ($result) use ($sftpTab, $statusLabel) { if (isset($result['error'])) { $statusLabel->setText('Fehler beim Aktualisieren: ' . $result['error']); return; } if (isset($result['success'])) { $sftpTab->remoteFileBrowser->setPath($result['path']); $sftpTab->remoteFileBrowser->setFileData($result['files']); } }, function ($error) use ($statusLabel) { $errorMsg = is_string($error) ? $error : 'Unknown error'; $statusLabel->setText('Fehler beim Aktualisieren: ' . $errorMsg); }, ); $loadButton->handleMouseClick(0, 0, 0); } private function handleUpload( string &$currentPrivateKeyPath, ServerListTab $serverListTab, Label $statusLabel, ): void { if ($this->currentLocalSelection === null) { $statusLabel->setText('Keine lokale Datei ausgewählt'); return; } $localRow = $this->currentLocalSelection['row'] ?? null; $localPath = $this->currentLocalSelection['path'] ?? null; if ($localRow === null || $localPath === null) { $statusLabel->setText('Ungültige lokale Auswahl'); return; } if ($serverListTab->selectedServer === null) { $statusLabel->setText('Kein Server ausgewählt'); return; } if (empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { $statusLabel->setText('Private Key Pfad nicht konfiguriert oder Datei nicht gefunden'); return; } $remoteDir = $this->remoteFileBrowser->getCurrentPath(); if ($remoteDir === '') { $remoteDir = '/'; } // Upload-Queue inkl. Byte-Größen vorbereiten $this->pendingUploadQueue = $this->buildUploadQueue($localPath, $localRow, $remoteDir); $this->totalUploadFiles = count($this->pendingUploadQueue); $this->completedUploadFiles = 0; $this->totalUploadBytes = 0; $this->completedUploadBytes = 0; foreach ($this->pendingUploadQueue as $item) { $this->totalUploadBytes += (int) ($item['size'] ?? 0); } $this->transferProgressBar->setValue(0.0); $this->transferInfoLabel->setText( sprintf( 'Upload: %d Dateien', $this->totalUploadFiles, ), ); $this->transferBytesLabel->setText( sprintf( '0.00 / %.2f MB', $this->totalUploadBytes > 0 ? ($this->totalUploadBytes / (1024 * 1024)) : 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 */ 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), 'size' => is_file($localPath) ? (int) @filesize($localPath) : 0, ]; return $queue; } // Wurzelordner auf Remote $rootRemote = $remoteBase . '/' . basename($localPath); $this->collectUploadFilesRecursive($localPath, $rootRemote, $queue); return $queue; } /** * Rekursive Sammlung aller Dateien eines Ordners. * * @param array $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, 'size' => is_file($localChild) ? (int) @filesize($localChild) : 0, ]; } } } /** * 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']; $fileSize = (int) ($item['size'] ?? 0); $selectedServerRef = &$serverListTab->selectedServer; $sftpTab = $this; $uploadAsyncButton = new Button('UploadChunk', ''); $uploadAsyncButton->setOnClickAsync( function () use (&$currentPrivateKeyPath, &$selectedServerRef, $localPath, $remotePath) { 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']; } // 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']; } return ['success' => true]; } catch (\Exception $e) { return ['error' => $e->getMessage()]; } }, function ($result) use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel, $fileSize) { if (isset($result['error'])) { $sftpTab->transferInfoLabel->setText('Upload fehlgeschlagen: ' . $result['error']); $statusLabel->setText('Fehler beim Hochladen: ' . $result['error']); return; } if (isset($result['success'])) { $sftpTab->completedUploadFiles++; $sftpTab->completedUploadBytes += $fileSize; $progress = 0.0; if ($sftpTab->totalUploadBytes > 0) { $progress = $sftpTab->completedUploadBytes / $sftpTab->totalUploadBytes; } elseif ($sftpTab->totalUploadFiles > 0) { $progress = $sftpTab->completedUploadFiles / $sftpTab->totalUploadFiles; } else { $progress = 1.0; } $sftpTab->transferProgressBar->setValue($progress); $sftpTab->transferInfoLabel->setText( sprintf( 'Upload: %d / %d Dateien', $sftpTab->completedUploadFiles, $sftpTab->totalUploadFiles, ), ); $sftpTab->transferBytesLabel->setText( sprintf( '%.2f / %.2f MB', $sftpTab->completedUploadBytes > 0 ? ($sftpTab->completedUploadBytes / (1024 * 1024)) : 0, $sftpTab->totalUploadBytes > 0 ? ($sftpTab->totalUploadBytes / (1024 * 1024)) : 0, ), ); // Nächste Datei starten $sftpTab->startNextUploadTask($currentPrivateKeyPath, $serverListTab, $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); }, ); $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']; $fileSize = (int) ($item['size'] ?? 0); $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, $fileSize) { 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++; $sftpTab->completedDownloadBytes += $fileSize; $progress = 0.0; if ($sftpTab->totalDownloadBytes > 0) { $progress = $sftpTab->completedDownloadBytes / $sftpTab->totalDownloadBytes; } elseif ($sftpTab->totalDownloadFiles > 0) { $progress = $sftpTab->completedDownloadFiles / $sftpTab->totalDownloadFiles; } else { $progress = 1.0; } $sftpTab->transferProgressBar->setValue($progress); $sftpTab->transferInfoLabel->setText( sprintf( 'Download: %d / %d Dateien', $sftpTab->completedDownloadFiles, $sftpTab->totalDownloadFiles, ), ); $sftpTab->transferBytesLabel->setText( sprintf( '%.2f / %.2f MB', $sftpTab->completedDownloadBytes > 0 ? ($sftpTab->completedDownloadBytes / (1024 * 1024)) : 0, $sftpTab->totalDownloadBytes > 0 ? ($sftpTab->totalDownloadBytes / (1024 * 1024)) : 0, ), ); // 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, Label $statusLabel, ): void { if ($this->currentRemoteSelection === null) { $statusLabel->setText('Keine Remote-Datei ausgewählt'); return; } $remoteRow = $this->currentRemoteSelection['row'] ?? null; $remotePath = $this->currentRemoteSelection['path'] ?? null; if ($remoteRow === null || $remotePath === null) { $statusLabel->setText('Ungültige Remote-Auswahl'); return; } if ($serverListTab->selectedServer === null) { $statusLabel->setText('Kein Server ausgewählt'); return; } if (empty($currentPrivateKeyPath) || !file_exists($currentPrivateKeyPath)) { $statusLabel->setText('Private Key Pfad nicht konfiguriert oder Datei nicht gefunden'); return; } $localDir = $this->localFileBrowser->getCurrentPath(); if ($localDir === '') { $localDir = getcwd(); } if (!is_dir($localDir) || !is_writable($localDir)) { $statusLabel->setText('Lokales Verzeichnis ist nicht beschreibbar'); return; } $this->transferProgressBar->setValue(0.0); $this->transferInfoLabel->setText('Download wird vorbereitet …'); // Async-Scan der Remote-Struktur, um eine Download-Queue aufzubauen $selectedServerRef = &$serverListTab->selectedServer; $sftpTab = $this; $scanButton = new Button('ScanDownload', ''); $scanButton->setOnClickAsync( function () use (&$currentPrivateKeyPath, &$selectedServerRef, $remotePath, $remoteRow, $localDir) { 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']; } $queue = []; $files = 0; $dirs = 0; $totalBytes = 0; $isDir = (bool) ($remoteRow['isDir'] ?? false); if (!$isDir) { $stat = $sftp->stat($remotePath); $size = (int) ($stat['size'] ?? 0); $queue[] = [ 'remote' => $remotePath, 'local' => rtrim($localDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . basename($remotePath), 'size' => $size, ]; $files = 1; $totalBytes = $size; } else { $rootLocal = rtrim($localDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . basename($remotePath); $collect = null; $collect = function (string $srcDir, string $dstDir) use (&$collect, $sftp, &$queue, &$files, &$dirs, &$totalBytes) { $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 { $size = (int) ($stat['size'] ?? 0); $queue[] = [ 'remote' => $remoteChild, 'local' => $localChild, 'size' => $size, ]; $files++; $totalBytes += $size; } } }; $collect($remotePath, $rootLocal); } return [ 'success' => true, 'queue' => $queue, 'files' => $files, 'dirs' => $dirs, 'localDir' => $localDir, 'totalBytes' => $totalBytes, ]; } catch (\Exception $e) { return ['error' => $e->getMessage()]; } }, function ($result) use ($sftpTab, $statusLabel, &$currentPrivateKeyPath, $serverListTab) { if (isset($result['error'])) { $statusLabel->setText('Fehler beim Vorbereiten des Downloads: ' . $result['error']); $sftpTab->transferInfoLabel->setText('Download fehlgeschlagen'); return; } 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); $totalBytes = (int) ($result['totalBytes'] ?? 0); $sftpTab->pendingDownloadQueue = $queue; $sftpTab->totalDownloadFiles = count($queue); $sftpTab->completedDownloadFiles = 0; $sftpTab->totalDownloadBytes = $totalBytes; $sftpTab->completedDownloadBytes = 0; if ($sftpTab->totalDownloadFiles <= 0) { $sftpTab->transferProgressBar->setValue(1.0); $sftpTab->transferInfoLabel->setText('Keine Dateien zum Herunterladen'); $sftpTab->transferBytesLabel->setText(''); return; } $sftpTab->transferProgressBar->setValue(0.0); $sftpTab->transferInfoLabel->setText( sprintf( 'Download: %d Dateien, %d Ordner', $files, $dirs, ), ); $sftpTab->transferBytesLabel->setText( sprintf( '0.00 / %.2f MB', $totalBytes > 0 ? ($totalBytes / (1024 * 1024)) : 0, ), ); $localDir = $result['localDir'] ?? ''; $sftpTab->startNextDownloadTask($currentPrivateKeyPath, $serverListTab, $statusLabel, $localDir); }, function ($error) use ($sftpTab, $statusLabel) { $errorMsg = is_string($error) ? $error : 'Unknown error'; $statusLabel->setText('Fehler beim Vorbereiten des Downloads: ' . $errorMsg); $sftpTab->transferInfoLabel->setText('Download fehlgeschlagen'); }, ); $scanButton->handleMouseClick(0, 0, 0); } private function createRenameModal( string &$currentPrivateKeyPath, ServerListTab $serverListTab, Label $statusLabel, ): void { // Create modal content $modalContent = new Container('flex flex-col bg-white rounded-lg shadow-lg p-4 gap-3 w-96'); // Title $modalContent->addComponent(new Label('Datei umbenennen', 'text-lg font-bold text-black')); // Filename input $this->renameInput = new TextInput( 'Neuer Dateiname', 'w-full border-2 border-gray-300 rounded px-3 py-2 bg-white text-black', ); $modalContent->addComponent($this->renameInput); // Button container $buttonContainer = new Container('flex flex-row justify-end gap-2'); // Cancel button $cancelButton = new Button('Abbrechen', 'px-4 py-2 bg-gray-300 text-black rounded hover:bg-gray-400'); $sftpTab = $this; $cancelButton->setOnClick(function () use ($sftpTab) { $sftpTab->renameModal->setVisible(false); }); $buttonContainer->addComponent($cancelButton); // Rename button $renameButton = new Button('Umbenennen', 'px-4 py-2 bg-amber-600 text-white rounded hover:bg-amber-700'); $renameButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { $newFilename = trim($sftpTab->renameInput->getValue()); if (empty($newFilename)) { $statusLabel->setText('Dateiname darf nicht leer sein'); return; } $oldPath = $sftpTab->currentRenameFilePath; $directory = dirname($oldPath); $newPath = $directory . '/' . $newFilename; // Close rename modal $sftpTab->renameModal->setVisible(false); // Perform rename via SFTP $selectedServerRef = &$serverListTab->selectedServer; $renameAsyncButton = new Button('Rename', ''); $renameAsyncButton->setOnClickAsync( function () use ($oldPath, $newPath, &$currentPrivateKeyPath, &$selectedServerRef) { 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']; } $result = $sftp->rename($oldPath, $newPath); if ($result === false) { return ['error' => 'Could not rename file']; } return ['success' => true]; } catch (\Exception $e) { return ['error' => $e->getMessage()]; } }, function ($result) use ($sftpTab, $statusLabel, &$currentPrivateKeyPath, &$selectedServerRef) { if (isset($result['error'])) { $statusLabel->setText('Fehler beim Umbenennen: ' . $result['error']); return; } if (isset($result['success'])) { $statusLabel->setText('Datei erfolgreich umbenannt'); $sftpTab->reloadCurrentDirectory($currentPrivateKeyPath, $selectedServerRef, $statusLabel); } }, function ($error) use ($statusLabel) { $errorMsg = is_string($error) ? $error : 'Unknown error'; $statusLabel->setText('Fehler beim Umbenennen: ' . $errorMsg); }, ); $renameAsyncButton->handleMouseClick(0, 0, 0); }); $buttonContainer->addComponent($renameButton); $modalContent->addComponent($buttonContainer); // Create modal $this->renameModal = new Modal($modalContent); } private function createDeleteConfirmModal( string &$currentPrivateKeyPath, ServerListTab $serverListTab, Label $statusLabel, ): void { // Create modal content $modalContent = new Container('flex flex-col bg-white rounded-lg shadow-lg p-4 gap-3 w-96'); // Title $modalContent->addComponent(new Label('Datei löschen', 'text-lg font-bold text-black')); // Confirmation message $this->deleteConfirmLabel = new Label( 'Möchten Sie diese Datei wirklich löschen?', 'text-sm text-gray-700', ); $modalContent->addComponent($this->deleteConfirmLabel); // Button container $buttonContainer = new Container('flex flex-row justify-end gap-2'); // Cancel button $cancelButton = new Button('Abbrechen', 'px-4 py-2 bg-gray-300 text-black rounded hover:bg-gray-400'); $sftpTab = $this; $cancelButton->setOnClick(function () use ($sftpTab) { $sftpTab->deleteConfirmModal->setVisible(false); }); $buttonContainer->addComponent($cancelButton); // Delete button $deleteButton = new Button('Löschen', 'px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700'); $deleteButton->setOnClick(function () use ($sftpTab, &$currentPrivateKeyPath, $serverListTab, $statusLabel) { $filePath = $sftpTab->currentDeleteFilePath; // Close delete modal $sftpTab->deleteConfirmModal->setVisible(false); // Perform delete via SFTP $selectedServerRef = &$serverListTab->selectedServer; $deleteAsyncButton = new Button('Delete', ''); $deleteAsyncButton->setOnClickAsync( function () use ($filePath, &$currentPrivateKeyPath, &$selectedServerRef) { 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']; } $result = $sftp->delete($filePath); if ($result === false) { return ['error' => 'Could not delete file']; } return ['success' => true]; } catch (\Exception $e) { return ['error' => $e->getMessage()]; } }, function ($result) use ($sftpTab, $statusLabel, &$currentPrivateKeyPath, &$selectedServerRef) { if (isset($result['error'])) { $statusLabel->setText('Fehler beim Löschen: ' . $result['error']); return; } if (isset($result['success'])) { $statusLabel->setText('Datei erfolgreich gelöscht'); $sftpTab->reloadCurrentDirectory($currentPrivateKeyPath, $selectedServerRef, $statusLabel); } }, function ($error) use ($statusLabel) { $errorMsg = is_string($error) ? $error : 'Unknown error'; $statusLabel->setText('Fehler beim Löschen: ' . $errorMsg); }, ); $deleteAsyncButton->handleMouseClick(0, 0, 0); }); $buttonContainer->addComponent($deleteButton); $modalContent->addComponent($buttonContainer); // Create modal $this->deleteConfirmModal = new Modal($modalContent); } private function handleFileRename( string $path, array $row, string &$currentPrivateKeyPath, ServerListTab $serverListTab, Label $statusLabel, ): void { if ($serverListTab->selectedServer === null) { $statusLabel->setText('Kein Server ausgewählt'); return; } $this->currentRenameFilePath = $path; $filename = basename($path); $this->renameInput->setValue($filename); $this->renameModal->setVisible(true); } private function handleFileDelete( string $path, array $row, string &$currentPrivateKeyPath, ServerListTab $serverListTab, Label $statusLabel, ): void { if ($serverListTab->selectedServer === null) { $statusLabel->setText('Kein Server ausgewählt'); return; } $this->currentDeleteFilePath = $path; $filename = basename($path); $this->deleteConfirmLabel->setText('Möchten Sie die Datei "' . $filename . '" wirklich löschen?'); $this->deleteConfirmModal->setVisible(true); } }