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

382 lines
13 KiB
PHP

<?php
namespace PHPNative\Ui\Widget;
class FileBrowser extends Container
{
private Table $fileTable;
private Label $pathLabel;
private string $currentPath;
private $onFileSelect = null;
private $onEditFile = null;
private $onRenameFile = null;
private $onDeleteFile = null;
private bool $isRemote = false;
public function __construct(string $initialPath = '.', bool $isRemote = false, string $style = '')
{
parent::__construct('w-full flex flex-col 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);
// File table with explicit flex-1 for scrolling
$this->fileTable = new Table('');
$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);
// 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);
// Handle row selection
$fileBrowser = $this;
$this->fileTable->setOnRowSelect(function ($index, $row) use ($fileBrowser) {
if ($row && isset($row['isDir']) && $row['isDir'] && !empty($row['path'])) {
// Navigate to directory
$fileBrowser->loadDirectory($row['path']);
} elseif ($row && isset($row['path']) && !empty($row['path']) && $fileBrowser->onFileSelect !== null) {
// File selected
($fileBrowser->onFileSelect)($row['path'], $row);
}
});
}
/**
* 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);
// 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 py-1 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('', 'p-1 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('', 'p-1 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('', 'p-1 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);
// 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);
}
}
});
}
}