currentPath = $initialPath; $this->isRemote = $isRemote; // Path display $this->pathLabel = new Label($initialPath, 'px-3 py-2 bg-gray-200 text-black rounded text-sm font-mono'); $this->addComponent($this->pathLabel); // 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'], ['key' => 'size', 'title' => 'Größe', 'width' => 100], ['key' => 'modified', 'title' => 'Geändert', 'width' => 200], ['key' => 'actions', 'title' => 'Action', 'width' => 100, 'render' => [$this, 'renderActionsCell']], ]); $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); } } /** * Load directory contents (local only) */ public function loadDirectory(string $path): void { if ($this->isRemote) { return; // Remote loading is handled separately } $this->currentPath = realpath($path) ?: $path; $this->pathLabel->setText($this->currentPath); $files = []; // Add parent directory entry if not at root if ($this->currentPath !== '/') { $files[] = [ 'type' => 'DIR', 'name' => '..', 'size' => '', 'modified' => '', 'path' => dirname($this->currentPath), 'isDir' => true, ]; } // Read directory try { $entries = scandir($this->currentPath); foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $fullPath = $this->currentPath . DIRECTORY_SEPARATOR . $entry; $isDir = is_dir($fullPath); $mtime = filemtime($fullPath); $files[] = [ 'type' => $isDir ? 'DIR' : 'FILE', 'name' => $entry, 'size' => $isDir ? '' : $this->formatSize(filesize($fullPath)), 'modified' => $mtime ? date('Y-m-d H:i:s', $mtime) : '', 'path' => $fullPath, 'isDir' => $isDir, ]; } } catch (\Exception $e) { // Handle permission errors $files[] = [ 'type' => 'ERR', 'name' => 'Fehler: ' . $e->getMessage(), 'size' => '', 'modified' => '', 'path' => '', 'isDir' => false, ]; } $this->fileTable->setData($files); $this->updateSummaryFromFiles($files); // Handle row selection $fileBrowser = $this; $this->fileTable->setOnRowSelect(function ($index, $row) use ($fileBrowser) { if (!$row || empty($row['path'])) { return; } $path = $row['path']; // Immer Auswahl-Callback auslösen (z.B. für Upload) if ($fileBrowser->onFileSelect !== null) { ($fileBrowser->onFileSelect)($path, $row); } // Nur Verzeichnisse navigieren – per Doppelklick if (!($row['isDir'] ?? false)) { return; } $now = microtime(true); $doubleClickThreshold = 0.4; // Sekunden if ($fileBrowser->lastClickPath !== $path || ($now - $fileBrowser->lastClickTime) > $doubleClickThreshold) { // Erster Klick: nur merken $fileBrowser->lastClickPath = $path; $fileBrowser->lastClickTime = $now; return; } // Zweiter Klick innerhalb des Zeitfensters -> als Doppelklick werten $fileBrowser->lastClickPath = null; $fileBrowser->lastClickTime = 0.0; $fileBrowser->loadDirectory($path); }); } /** * Load remote directory contents via SFTP */ public function loadRemoteDirectory(string $path, $sftpConnection): void { if (!$this->isRemote) { return; } $this->currentPath = $path; $this->pathLabel->setText($path); $files = []; // Add parent directory entry if not at root if ($path !== '/') { $parentPath = dirname($path); if ($parentPath === '.') { $parentPath = '/'; } $files[] = [ 'type' => 'DIR', 'name' => '..', 'size' => '', 'modified' => '', 'path' => $parentPath, 'isDir' => true, ]; } // Read remote directory try { $entries = $sftpConnection->nlist($path); if ($entries === false) { throw new \Exception('Cannot read directory'); } foreach ($entries as $entry) { if ($entry === '.' || $entry === '..') { continue; } $fullPath = rtrim($path, '/') . '/' . $entry; $stat = $sftpConnection->stat($fullPath); $isDir = ($stat['type'] ?? 0) === 2; // NET_SFTP_TYPE_DIRECTORY $mtime = $stat['mtime'] ?? 0; $files[] = [ 'type' => $isDir ? 'DIR' : 'FILE', 'name' => $entry, 'size' => $isDir ? '' : $this->formatSize($stat['size'] ?? 0), 'modified' => $mtime ? date('Y-m-d H:i:s', $mtime) : '', 'path' => $fullPath, 'isDir' => $isDir, ]; } } catch (\Exception $e) { $files[] = [ 'type' => 'ERR', 'name' => 'Fehler: ' . $e->getMessage(), 'size' => '', 'modified' => '', 'path' => '', 'isDir' => false, ]; } $this->fileTable->setData($files); $this->updateSummaryFromFiles($files); // Handle row selection $fileBrowser = $this; $this->fileTable->setOnRowSelect(function ($index, $row) use ($fileBrowser, $sftpConnection) { if ($row && isset($row['isDir']) && $row['isDir'] && !empty($row['path'])) { // Navigate to directory $fileBrowser->loadRemoteDirectory($row['path'], $sftpConnection); } elseif ($row && isset($row['path']) && !empty($row['path']) && $fileBrowser->onFileSelect !== null) { // File selected ($fileBrowser->onFileSelect)($row['path'], $row); } }); } /** * Format file size */ private function formatSize(int|float $bytes): string { $units = ['B', 'KB', 'MB', 'GB', 'TB']; $bytes = max($bytes, 0); $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); $pow = min($pow, count($units) - 1); $bytes /= pow(1024, $pow); return round($bytes, 2) . ' ' . $units[$pow]; } /** * Set file select callback */ public function setOnFileSelect(callable $callback): void { $this->onFileSelect = $callback; } /** * Set edit file callback */ public function setOnEditFile(callable $callback): void { $this->onEditFile = $callback; } /** * Set rename file callback */ public function setOnRenameFile(callable $callback): void { $this->onRenameFile = $callback; } /** * Set delete file callback */ public function setOnDeleteFile(callable $callback): void { $this->onDeleteFile = $callback; } /** * Render actions cell with edit, rename, and delete buttons for files */ public function renderActionsCell(array $rowData, int $rowIndex): Container { // Match the cell style from Table (100px width for icon buttons) $container = new Container('w-25 border-r border-gray-300 flex flex-row items-center justify-center gap-1'); // Only show action buttons for files (not directories) if (!($rowData['isDir'] ?? false) && !empty($rowData['path'])) { $fileBrowser = $this; // Edit button $editButton = new Button('', 'text-blue-500 hover:text-blue-600 flex items-center justify-center'); $editIcon = new Icon(\PHPNative\Tailwind\Data\Icon::edit, 16, 'text-blue-500'); $editButton->setIcon($editIcon); $editButton->setOnClick(function () use ($fileBrowser, $rowData) { if ($fileBrowser->onEditFile !== null) { ($fileBrowser->onEditFile)($rowData['path'], $rowData); } }); $container->addComponent($editButton); // Rename button $renameButton = new Button('', 'text-amber-500 hover:text-amber-600 flex items-center justify-center'); $renameIcon = new Icon(\PHPNative\Tailwind\Data\Icon::pen, 16, 'text-amber-500'); $renameButton->setIcon($renameIcon); $renameButton->setOnClick(function () use ($fileBrowser, $rowData) { if ($fileBrowser->onRenameFile !== null) { ($fileBrowser->onRenameFile)($rowData['path'], $rowData); } }); $container->addComponent($renameButton); // Delete button $deleteButton = new Button('', 'text-red-500 hover:text-red-600 flex items-center justify-center'); $deleteIcon = new Icon(\PHPNative\Tailwind\Data\Icon::trash, 16, 'text-red-500'); $deleteButton->setIcon($deleteIcon); $deleteButton->setOnClick(function () use ($fileBrowser, $rowData) { if ($fileBrowser->onDeleteFile !== null) { ($fileBrowser->onDeleteFile)($rowData['path'], $rowData); } }); $container->addComponent($deleteButton); } return $container; } /** * Get current path */ public function getCurrentPath(): string { return $this->currentPath; } /** * Set path (updates label only, doesn't reload) */ public function setPath(string $path): void { $this->currentPath = $path; $this->pathLabel->setText($path); } /** * Set file data directly (for remote browsers where data comes from async operations) */ public function setFileData(array $files): void { $tableData = []; // Add parent directory if not at root if ($this->currentPath !== '/') { $parentPath = dirname($this->currentPath); if ($parentPath === '.') { $parentPath = '/'; } $tableData[] = [ 'type' => 'DIR', 'name' => '..', 'size' => '', 'modified' => '', 'path' => $parentPath, 'isDir' => true, ]; } // Add files foreach ($files as $file) { $tableData[] = [ 'type' => $file['isDir'] ? 'DIR' : 'FILE', 'name' => $file['name'], 'size' => $file['isDir'] ? '' : $this->formatSize($file['size']), 'modified' => isset($file['mtime']) ? date('Y-m-d H:i:s', $file['mtime']) : '', 'path' => $file['path'], 'isDir' => $file['isDir'], ]; } $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(); } /** * Setup navigation handler for remote file browser */ private function setupRemoteNavigationHandler(): void { if (!$this->isRemote) { return; } $fileBrowser = $this; $this->fileTable->setOnRowSelect(function ($index, $row) use ($fileBrowser) { if ($row && isset($row['isDir']) && $row['isDir'] && !empty($row['path'])) { // Trigger the external callback for directory navigation if ($fileBrowser->onFileSelect !== null) { ($fileBrowser->onFileSelect)($row['path'], $row); } } elseif ($row && isset($row['path']) && !empty($row['path']) && !($row['isDir'] ?? false)) { // File selected if ($fileBrowser->onFileSelect !== null) { ($fileBrowser->onFileSelect)($row['path'], $row); } } }); } /** * 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)); } }