sdl3/src/Ui/Widget/FileBrowser.php
2025-12-01 11:23:20 +01:00

441 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace PHPNative\Ui\Widget;
class FileBrowser extends Container
{
private VirtualListView $fileTable;
private Label $pathLabel;
private Label $summaryLabel;
private string $currentPath;
private $onFileSelect = null;
private $onEditFile = null;
private $onRenameFile = null;
private $onDeleteFile = null;
private bool $isRemote = false;
private null|string $lastClickPath = null;
private float $lastClickTime = 0.0;
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 VirtualListView für performantes Scrollen.
parent::__construct('w-full flex flex-col flex-1 gap-2 ' . $style);
$this->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));
}
}