441 lines
14 KiB
PHP
441 lines
14 KiB
PHP
<?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));
|
||
}
|
||
}
|