Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/files/appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<command>OCA\Files\Command\ScanAppData</command>
<command>OCA\Files\Command\RepairTree</command>
<command>OCA\Files\Command\Get</command>
<command>OCA\Files\Command\ListCommand</command>
<command>OCA\Files\Command\Put</command>
<command>OCA\Files\Command\Delete</command>
<command>OCA\Files\Command\Copy</command>
Expand Down
1 change: 1 addition & 0 deletions apps/files/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
'OCA\\Files\\Command\\Delete' => $baseDir . '/../lib/Command/Delete.php',
'OCA\\Files\\Command\\DeleteOrphanedFiles' => $baseDir . '/../lib/Command/DeleteOrphanedFiles.php',
'OCA\\Files\\Command\\Get' => $baseDir . '/../lib/Command/Get.php',
'OCA\\Files\\Command\\ListCommand' => $baseDir . '/../lib/Command/ListCommand.php',
'OCA\\Files\\Command\\Mkdir' => $baseDir . '/../lib/Command/Mkdir.php',
'OCA\\Files\\Command\\Mount\\ListMounts' => $baseDir . '/../lib/Command/Mount/ListMounts.php',
'OCA\\Files\\Command\\Mount\\Refresh' => $baseDir . '/../lib/Command/Mount/Refresh.php',
Expand Down
1 change: 1 addition & 0 deletions apps/files/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class ComposerStaticInitFiles
'OCA\\Files\\Command\\Delete' => __DIR__ . '/..' . '/../lib/Command/Delete.php',
'OCA\\Files\\Command\\DeleteOrphanedFiles' => __DIR__ . '/..' . '/../lib/Command/DeleteOrphanedFiles.php',
'OCA\\Files\\Command\\Get' => __DIR__ . '/..' . '/../lib/Command/Get.php',
'OCA\\Files\\Command\\ListCommand' => __DIR__ . '/..' . '/../lib/Command/ListCommand.php',
'OCA\\Files\\Command\\Mkdir' => __DIR__ . '/..' . '/../lib/Command/Mkdir.php',
'OCA\\Files\\Command\\Mount\\ListMounts' => __DIR__ . '/..' . '/../lib/Command/Mount/ListMounts.php',
'OCA\\Files\\Command\\Mount\\Refresh' => __DIR__ . '/..' . '/../lib/Command/Mount/Refresh.php',
Expand Down
118 changes: 118 additions & 0 deletions apps/files/lib/Command/ListCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Files\Command;

use DateTimeImmutable;
use OC\Core\Command\Info\FileUtils;
use OCP\Console\Attribute\Argument;
use OCP\Console\Attribute\AsCommand;
use OCP\Console\ExitCode;
use OCP\Console\IOutput;
use OCP\Files\Folder;
use OCP\Files\Node;
use OCP\IUserManager;

#[AsCommand(
name: 'files:list',
description: 'List the contents of a folder',
supportsOutputFormat: true,
)]
class ListCommand {
public function __construct(
private readonly FileUtils $fileUtils,
private readonly IUserManager $userManager,
) {
}

public function __invoke(
IOutput $output,
#[Argument(description: 'Nextcloud path or fileid of the folder to list', suggestedValues: [self::class, 'suggestPaths'])]
string $file,
): ExitCode {
$node = $this->fileUtils->getNode($file);

if (!$node) {
$output->writeln("<error>file $file not found</error>");
return ExitCode::Failure;
}

if (!($node instanceof Folder)) {
$output->writeln("<error>$file is not a folder, use <info>occ info:file $file</info> instead</error>");
return ExitCode::Failure;
}

$entries = [];
foreach ($node->getDirectoryListing() as $child) {
$entries[$child->getName()] = $child;
}
ksort($entries);

if ($node->getPath() === '/') {
// User homes are separate mounts set up on demand, not children of the root
// storage itself, so getDirectoryListing() alone would miss them. Listed after
// the root's own entries (e.g. appdata) rather than sorted in among them.
$userHomes = [];
foreach ($this->userManager->search('') as $user) {

Check failure on line 61 in apps/files/lib/Command/ListCommand.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

DeprecatedMethod

apps/files/lib/Command/ListCommand.php:61:33: DeprecatedMethod: The method OCP\IUserManager::search has been marked as deprecated (see https://psalm.dev/001)
$home = $this->fileUtils->getNode($user->getUID() . '/files');
if ($home instanceof Folder) {
$userHomes[$user->getUID()] = $home;
}
}
ksort($userHomes);
$entries += $userHomes;
}

$output->writeTableInOutputFormat(array_map($this->nodeToRow(...), array_keys($entries), $entries));

return ExitCode::Success;
}

private function nodeToRow(string $name, Node $node): array {
return [
'fileid' => $node->getId(),
'name' => $name,
'type' => $node instanceof Folder ? 'folder' : $node->getMimetype(),
'size' => $node->getSize(),
'mtime' => (new DateTimeImmutable('@' . $node->getMTime()))->format(DATE_ATOM),
'permissions' => $this->fileUtils->formatPermissions($node->getType(), $node->getPermissions()),
];
}

/**
* Suggests usernames for the first path segment, folder children for the rest.
*
* @return string[]
*/
public function suggestPaths(string $currentWord): array {
$lastSlash = strrpos($currentWord, '/');
if ($lastSlash === false) {
$suggestions = [];
foreach ($this->userManager->search($currentWord) as $user) {

Check failure on line 96 in apps/files/lib/Command/ListCommand.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

DeprecatedMethod

apps/files/lib/Command/ListCommand.php:96:33: DeprecatedMethod: The method OCP\IUserManager::search has been marked as deprecated (see https://psalm.dev/001)
$suggestions[] = $user->getUID() . '/';
}
return $suggestions;
}

$prefix = substr($currentWord, 0, $lastSlash + 1);
$partial = substr($currentWord, $lastSlash + 1);

$parent = $this->fileUtils->getNode(rtrim($prefix, '/'));
if (!($parent instanceof Folder)) {
return [];
}

$suggestions = [];
foreach ($parent->getDirectoryListing() as $child) {
if ($partial === '' || str_starts_with($child->getName(), $partial)) {
$suggestions[] = $prefix . $child->getName() . ($child instanceof Folder ? '/' : '');
}
}
return $suggestions;
}
}
218 changes: 218 additions & 0 deletions apps/files/tests/Command/ListCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Files\Tests\Command;

use OC\Core\Command\Info\FileUtils;
use OCA\Files\Command\ListCommand;
use OCP\Console\ExitCode;
use OCP\Console\IOutput;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\IUser;
use OCP\IUserManager;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;

class ListCommandTest extends TestCase {
private FileUtils&MockObject $fileUtils;
private IUserManager&MockObject $userManager;
private IOutput&MockObject $output;
private ListCommand $command;

#[\Override]
protected function setUp(): void {
parent::setUp();
$this->fileUtils = $this->createMock(FileUtils::class);
$this->userManager = $this->createMock(IUserManager::class);
$this->output = $this->createMock(IOutput::class);
$this->command = new ListCommand($this->fileUtils, $this->userManager);
}

public function testFailsWhenNodeIsNotFound(): void {
$this->fileUtils->method('getNode')->with('/does-not-exist')->willReturn(null);

$this->output->expects($this->once())
->method('writeln')
->with($this->stringContains('not found'));
$this->output->expects($this->never())->method('writeTableInOutputFormat');

$result = ($this->command)($this->output, '/does-not-exist');
$this->assertEquals(ExitCode::Failure, $result);
}

public function testFailsWhenNodeIsAFile(): void {
$file = $this->createMock(File::class);
$this->fileUtils->method('getNode')->with('/some/file.txt')->willReturn($file);

$this->output->expects($this->once())
->method('writeln')
->with($this->stringContains('is not a folder'));
$this->output->expects($this->never())->method('writeTableInOutputFormat');

$result = ($this->command)($this->output, '/some/file.txt');
$this->assertEquals(ExitCode::Failure, $result);
}

public function testListsDirectoryContentsSortedByName(): void {
$subFolder = $this->createMock(Folder::class);
$subFolder->method('getId')->willReturn(10);
$subFolder->method('getName')->willReturn('b-folder');
$subFolder->method('getSize')->willReturn(1234);
$subFolder->method('getMTime')->willReturn(1000);
$subFolder->method('getType')->willReturn('dir');
$subFolder->method('getPermissions')->willReturn(31);

$file = $this->createMock(File::class);
$file->method('getId')->willReturn(11);
$file->method('getName')->willReturn('a-file.txt');
$file->method('getMimetype')->willReturn('text/plain');
$file->method('getSize')->willReturn(42);
$file->method('getMTime')->willReturn(2000);
$file->method('getType')->willReturn('file');
$file->method('getPermissions')->willReturn(27);

$folder = $this->createMock(Folder::class);
$folder->method('getDirectoryListing')->willReturn([$subFolder, $file]);
$this->fileUtils->method('getNode')->with('/some/folder')->willReturn($folder);
$this->fileUtils->method('formatPermissions')->willReturnCallback(
fn (string $type, int $permissions) => "$type:$permissions",
);

$this->output->expects($this->once())
->method('writeTableInOutputFormat')
->with([
[
'fileid' => 11,
'name' => 'a-file.txt',
'type' => 'text/plain',
'size' => 42,
'mtime' => (new \DateTimeImmutable('@2000'))->format(\DATE_ATOM),
'permissions' => 'file:27',
],
[
'fileid' => 10,
'name' => 'b-folder',
'type' => 'folder',
'size' => 1234,
'mtime' => (new \DateTimeImmutable('@1000'))->format(\DATE_ATOM),
'permissions' => 'dir:31',
],
]);

$result = ($this->command)($this->output, '/some/folder');
$this->assertEquals(ExitCode::Success, $result);
}

public function testListsRegisteredUsersAfterTheRootsOwnEntriesWhenAtTheTrueRoot(): void {
$appdata = $this->createMock(Folder::class);
$appdata->method('getId')->willReturn(5);
$appdata->method('getName')->willReturn('appdata_oc498lxm75hw');
$appdata->method('getSize')->willReturn(0);
$appdata->method('getMTime')->willReturn(500);
$appdata->method('getType')->willReturn('dir');
$appdata->method('getPermissions')->willReturn(31);

$root = $this->createMock(Folder::class);
$root->method('getPath')->willReturn('/');
$root->method('getDirectoryListing')->willReturn([$appdata]);

$userB = $this->createMock(IUser::class);
$userB->method('getUID')->willReturn('bob');
$userA = $this->createMock(IUser::class);
$userA->method('getUID')->willReturn('alice');
$this->userManager->method('search')->with('')->willReturn([$userB, $userA]);

$bobHome = $this->createMock(Folder::class);
$bobHome->method('getId')->willReturn(20);
$bobHome->method('getSize')->willReturn(100);
$bobHome->method('getMTime')->willReturn(3000);
$bobHome->method('getType')->willReturn('dir');
$bobHome->method('getPermissions')->willReturn(31);

$aliceHome = $this->createMock(Folder::class);
$aliceHome->method('getId')->willReturn(21);
$aliceHome->method('getSize')->willReturn(200);
$aliceHome->method('getMTime')->willReturn(4000);
$aliceHome->method('getType')->willReturn('dir');
$aliceHome->method('getPermissions')->willReturn(31);

$this->fileUtils->method('getNode')->willReturnMap([
['/', $root],
['bob/files', $bobHome],
['alice/files', $aliceHome],
]);
$this->fileUtils->method('formatPermissions')->willReturn('full permissions');

$this->output->expects($this->once())
->method('writeTableInOutputFormat')
->with([
[
'fileid' => 5,
'name' => 'appdata_oc498lxm75hw',
'type' => 'folder',
'size' => 0,
'mtime' => (new \DateTimeImmutable('@500'))->format(\DATE_ATOM),
'permissions' => 'full permissions',
],
[
'fileid' => 21,
'name' => 'alice',
'type' => 'folder',
'size' => 200,
'mtime' => (new \DateTimeImmutable('@4000'))->format(\DATE_ATOM),
'permissions' => 'full permissions',
],
[
'fileid' => 20,
'name' => 'bob',
'type' => 'folder',
'size' => 100,
'mtime' => (new \DateTimeImmutable('@3000'))->format(\DATE_ATOM),
'permissions' => 'full permissions',
],
]);

$result = ($this->command)($this->output, '/');
$this->assertEquals(ExitCode::Success, $result);
}

public function testSuggestPathsSuggestsMatchingUsernamesForTheFirstSegment(): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('alice');
$this->userManager->method('search')->with('ali')->willReturn([$user]);

$this->assertEquals(['alice/'], $this->command->suggestPaths('ali'));
}

public function testSuggestPathsSuggestsMatchingChildrenForALaterSegment(): void {
$matchingFile = $this->createMock(File::class);
$matchingFile->method('getName')->willReturn('report.pdf');

$matchingFolder = $this->createMock(Folder::class);
$matchingFolder->method('getName')->willReturn('reports');

$nonMatching = $this->createMock(File::class);
$nonMatching->method('getName')->willReturn('other.txt');

$folder = $this->createMock(Folder::class);
$folder->method('getDirectoryListing')->willReturn([$matchingFile, $matchingFolder, $nonMatching]);
$this->fileUtils->method('getNode')->with('alice/files')->willReturn($folder);

$this->assertEquals(
['alice/files/report.pdf', 'alice/files/reports/'],
$this->command->suggestPaths('alice/files/re'),
);
}

public function testSuggestPathsReturnsNothingWhenTheParentIsNotAFolder(): void {
$this->fileUtils->method('getNode')->with('alice/files/report.pdf')->willReturn($this->createMock(File::class));

$this->assertEquals([], $this->command->suggestPaths('alice/files/report.pdf/x'));
}
}
4 changes: 3 additions & 1 deletion console.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,9 @@ function exceptionHandler($exception) {
'profiler' => 'db',
'token' => $profile->getToken(),
]);
$output->getErrorOutput()->writeln('Profiler output available at ' . $url);
if (!\in_array($input->getArgument('command'), ['_completion', '_complete'], true)) {
$output->getErrorOutput()->writeln('Profiler output available at ' . $url);
}
}

if ($exitCode > 255) {
Expand Down
Loading
Loading