Skip to content

Commit 12357e1

Browse files
authored
Merge pull request #64439 from nextcloud/backport/64024/stable35
[stable35] Add files:list command and fix auto completion
2 parents 3aa4074 + 44113ab commit 12357e1

9 files changed

Lines changed: 436 additions & 6 deletions

File tree

‎apps/files/appinfo/info.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
<command>OCA\Files\Command\ScanAppData</command>
4343
<command>OCA\Files\Command\RepairTree</command>
4444
<command>OCA\Files\Command\Get</command>
45+
<command>OCA\Files\Command\ListCommand</command>
4546
<command>OCA\Files\Command\Put</command>
4647
<command>OCA\Files\Command\Delete</command>
4748
<command>OCA\Files\Command\Copy</command>

‎apps/files/composer/composer/autoload_classmap.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
'OCA\\Files\\Command\\Delete' => $baseDir . '/../lib/Command/Delete.php',
3333
'OCA\\Files\\Command\\DeleteOrphanedFiles' => $baseDir . '/../lib/Command/DeleteOrphanedFiles.php',
3434
'OCA\\Files\\Command\\Get' => $baseDir . '/../lib/Command/Get.php',
35+
'OCA\\Files\\Command\\ListCommand' => $baseDir . '/../lib/Command/ListCommand.php',
3536
'OCA\\Files\\Command\\Mkdir' => $baseDir . '/../lib/Command/Mkdir.php',
3637
'OCA\\Files\\Command\\Mount\\ListMounts' => $baseDir . '/../lib/Command/Mount/ListMounts.php',
3738
'OCA\\Files\\Command\\Mount\\Refresh' => $baseDir . '/../lib/Command/Mount/Refresh.php',

‎apps/files/composer/composer/autoload_static.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class ComposerStaticInitFiles
4747
'OCA\\Files\\Command\\Delete' => __DIR__ . '/..' . '/../lib/Command/Delete.php',
4848
'OCA\\Files\\Command\\DeleteOrphanedFiles' => __DIR__ . '/..' . '/../lib/Command/DeleteOrphanedFiles.php',
4949
'OCA\\Files\\Command\\Get' => __DIR__ . '/..' . '/../lib/Command/Get.php',
50+
'OCA\\Files\\Command\\ListCommand' => __DIR__ . '/..' . '/../lib/Command/ListCommand.php',
5051
'OCA\\Files\\Command\\Mkdir' => __DIR__ . '/..' . '/../lib/Command/Mkdir.php',
5152
'OCA\\Files\\Command\\Mount\\ListMounts' => __DIR__ . '/..' . '/../lib/Command/Mount/ListMounts.php',
5253
'OCA\\Files\\Command\\Mount\\Refresh' => __DIR__ . '/..' . '/../lib/Command/Mount/Refresh.php',
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Files\Command;
10+
11+
use DateTimeImmutable;
12+
use OC\Core\Command\Info\FileUtils;
13+
use OCP\Console\Attribute\Argument;
14+
use OCP\Console\Attribute\AsCommand;
15+
use OCP\Console\ExitCode;
16+
use OCP\Console\IOutput;
17+
use OCP\Files\Folder;
18+
use OCP\Files\Node;
19+
use OCP\IUser;
20+
use OCP\IUserManager;
21+
22+
#[AsCommand(
23+
name: 'files:list',
24+
description: 'List the contents of a folder',
25+
supportsOutputFormat: true,
26+
)]
27+
class ListCommand {
28+
public function __construct(
29+
private readonly FileUtils $fileUtils,
30+
private readonly IUserManager $userManager,
31+
) {
32+
}
33+
34+
public function __invoke(
35+
IOutput $output,
36+
#[Argument(description: 'Nextcloud path or fileid of the folder to list', suggestedValues: [self::class, 'suggestPaths'])]
37+
string $file,
38+
): ExitCode {
39+
$node = $this->fileUtils->getNode($file);
40+
41+
if (!$node) {
42+
$output->writeln("<error>file $file not found</error>");
43+
return ExitCode::Failure;
44+
}
45+
46+
if (!($node instanceof Folder)) {
47+
$output->writeln("<error>$file is not a folder, use <info>occ info:file $file</info> instead</error>");
48+
return ExitCode::Failure;
49+
}
50+
51+
$entries = [];
52+
foreach ($node->getDirectoryListing() as $child) {
53+
$entries[$child->getName()] = $child;
54+
}
55+
ksort($entries);
56+
57+
if ($node->getPath() === '/') {
58+
// User homes are separate mounts set up on demand, not children of the root
59+
// storage itself, so getDirectoryListing() alone would miss them. Listed after
60+
// the root's own entries (e.g. appdata) rather than sorted in among them.
61+
$userHomes = [];
62+
$this->userManager->callForAllUsers(function (IUser $user) use (&$userHomes): void {
63+
$home = $this->fileUtils->getNode($user->getUID() . '/files');
64+
if ($home instanceof Folder) {
65+
$userHomes[$user->getUID()] = $home;
66+
}
67+
});
68+
ksort($userHomes);
69+
$entries += $userHomes;
70+
}
71+
72+
$output->writeTableInOutputFormat(array_map($this->nodeToRow(...), array_keys($entries), $entries));
73+
74+
return ExitCode::Success;
75+
}
76+
77+
private function nodeToRow(string $name, Node $node): array {
78+
return [
79+
'fileid' => $node->getId(),
80+
'name' => $name,
81+
'type' => $node instanceof Folder ? 'folder' : $node->getMimetype(),
82+
'size' => $node->getSize(),
83+
'mtime' => (new DateTimeImmutable('@' . $node->getMTime()))->format(DATE_ATOM),
84+
'permissions' => $this->fileUtils->formatPermissions($node->getType(), $node->getPermissions()),
85+
];
86+
}
87+
88+
/**
89+
* Suggests usernames for the first path segment, folder children for the rest.
90+
*
91+
* @return string[]
92+
*/
93+
public function suggestPaths(string $currentWord): array {
94+
$lastSlash = strrpos($currentWord, '/');
95+
if ($lastSlash === false) {
96+
$suggestions = [];
97+
$this->userManager->callForAllUsers(function (IUser $user) use (&$suggestions): void {
98+
$suggestions[] = $user->getUID() . '/';
99+
}, $currentWord);
100+
return $suggestions;
101+
}
102+
103+
$prefix = substr($currentWord, 0, $lastSlash + 1);
104+
$partial = substr($currentWord, $lastSlash + 1);
105+
106+
$parent = $this->fileUtils->getNode(rtrim($prefix, '/'));
107+
if (!($parent instanceof Folder)) {
108+
return [];
109+
}
110+
111+
$suggestions = [];
112+
foreach ($parent->getDirectoryListing() as $child) {
113+
if ($partial === '' || str_starts_with($child->getName(), $partial)) {
114+
$suggestions[] = $prefix . $child->getName() . ($child instanceof Folder ? '/' : '');
115+
}
116+
}
117+
return $suggestions;
118+
}
119+
}
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
/**
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
6+
* SPDX-License-Identifier: AGPL-3.0-or-later
7+
*/
8+
9+
namespace OCA\Files\Tests\Command;
10+
11+
use OC\Core\Command\Info\FileUtils;
12+
use OCA\Files\Command\ListCommand;
13+
use OCP\Console\ExitCode;
14+
use OCP\Console\IOutput;
15+
use OCP\Files\File;
16+
use OCP\Files\Folder;
17+
use OCP\IUser;
18+
use OCP\IUserManager;
19+
use PHPUnit\Framework\MockObject\MockObject;
20+
use Test\TestCase;
21+
22+
class ListCommandTest extends TestCase {
23+
private FileUtils&MockObject $fileUtils;
24+
private IUserManager&MockObject $userManager;
25+
private IOutput&MockObject $output;
26+
private ListCommand $command;
27+
28+
#[\Override]
29+
protected function setUp(): void {
30+
parent::setUp();
31+
$this->fileUtils = $this->createMock(FileUtils::class);
32+
$this->userManager = $this->createMock(IUserManager::class);
33+
$this->output = $this->createMock(IOutput::class);
34+
$this->command = new ListCommand($this->fileUtils, $this->userManager);
35+
}
36+
37+
public function testFailsWhenNodeIsNotFound(): void {
38+
$this->fileUtils->method('getNode')->with('/does-not-exist')->willReturn(null);
39+
40+
$this->output->expects($this->once())
41+
->method('writeln')
42+
->with($this->stringContains('not found'));
43+
$this->output->expects($this->never())->method('writeTableInOutputFormat');
44+
45+
$result = ($this->command)($this->output, '/does-not-exist');
46+
$this->assertEquals(ExitCode::Failure, $result);
47+
}
48+
49+
public function testFailsWhenNodeIsAFile(): void {
50+
$file = $this->createMock(File::class);
51+
$this->fileUtils->method('getNode')->with('/some/file.txt')->willReturn($file);
52+
53+
$this->output->expects($this->once())
54+
->method('writeln')
55+
->with($this->stringContains('is not a folder'));
56+
$this->output->expects($this->never())->method('writeTableInOutputFormat');
57+
58+
$result = ($this->command)($this->output, '/some/file.txt');
59+
$this->assertEquals(ExitCode::Failure, $result);
60+
}
61+
62+
public function testListsDirectoryContentsSortedByName(): void {
63+
$subFolder = $this->createMock(Folder::class);
64+
$subFolder->method('getId')->willReturn(10);
65+
$subFolder->method('getName')->willReturn('b-folder');
66+
$subFolder->method('getSize')->willReturn(1234);
67+
$subFolder->method('getMTime')->willReturn(1000);
68+
$subFolder->method('getType')->willReturn('dir');
69+
$subFolder->method('getPermissions')->willReturn(31);
70+
71+
$file = $this->createMock(File::class);
72+
$file->method('getId')->willReturn(11);
73+
$file->method('getName')->willReturn('a-file.txt');
74+
$file->method('getMimetype')->willReturn('text/plain');
75+
$file->method('getSize')->willReturn(42);
76+
$file->method('getMTime')->willReturn(2000);
77+
$file->method('getType')->willReturn('file');
78+
$file->method('getPermissions')->willReturn(27);
79+
80+
$folder = $this->createMock(Folder::class);
81+
$folder->method('getDirectoryListing')->willReturn([$subFolder, $file]);
82+
$this->fileUtils->method('getNode')->with('/some/folder')->willReturn($folder);
83+
$this->fileUtils->method('formatPermissions')->willReturnCallback(
84+
fn (string $type, int $permissions) => "$type:$permissions",
85+
);
86+
87+
$this->output->expects($this->once())
88+
->method('writeTableInOutputFormat')
89+
->with([
90+
[
91+
'fileid' => 11,
92+
'name' => 'a-file.txt',
93+
'type' => 'text/plain',
94+
'size' => 42,
95+
'mtime' => (new \DateTimeImmutable('@2000'))->format(\DATE_ATOM),
96+
'permissions' => 'file:27',
97+
],
98+
[
99+
'fileid' => 10,
100+
'name' => 'b-folder',
101+
'type' => 'folder',
102+
'size' => 1234,
103+
'mtime' => (new \DateTimeImmutable('@1000'))->format(\DATE_ATOM),
104+
'permissions' => 'dir:31',
105+
],
106+
]);
107+
108+
$result = ($this->command)($this->output, '/some/folder');
109+
$this->assertEquals(ExitCode::Success, $result);
110+
}
111+
112+
public function testListsRegisteredUsersAfterTheRootsOwnEntriesWhenAtTheTrueRoot(): void {
113+
$appdata = $this->createMock(Folder::class);
114+
$appdata->method('getId')->willReturn(5);
115+
$appdata->method('getName')->willReturn('appdata_oc498lxm75hw');
116+
$appdata->method('getSize')->willReturn(0);
117+
$appdata->method('getMTime')->willReturn(500);
118+
$appdata->method('getType')->willReturn('dir');
119+
$appdata->method('getPermissions')->willReturn(31);
120+
121+
$root = $this->createMock(Folder::class);
122+
$root->method('getPath')->willReturn('/');
123+
$root->method('getDirectoryListing')->willReturn([$appdata]);
124+
125+
$userB = $this->createMock(IUser::class);
126+
$userB->method('getUID')->willReturn('bob');
127+
$userA = $this->createMock(IUser::class);
128+
$userA->method('getUID')->willReturn('alice');
129+
$this->userManager->method('callForAllUsers')
130+
->willReturnCallback(function (\Closure $callback) use ($userB, $userA): void {
131+
$callback($userB);
132+
$callback($userA);
133+
});
134+
135+
$bobHome = $this->createMock(Folder::class);
136+
$bobHome->method('getId')->willReturn(20);
137+
$bobHome->method('getSize')->willReturn(100);
138+
$bobHome->method('getMTime')->willReturn(3000);
139+
$bobHome->method('getType')->willReturn('dir');
140+
$bobHome->method('getPermissions')->willReturn(31);
141+
142+
$aliceHome = $this->createMock(Folder::class);
143+
$aliceHome->method('getId')->willReturn(21);
144+
$aliceHome->method('getSize')->willReturn(200);
145+
$aliceHome->method('getMTime')->willReturn(4000);
146+
$aliceHome->method('getType')->willReturn('dir');
147+
$aliceHome->method('getPermissions')->willReturn(31);
148+
149+
$this->fileUtils->method('getNode')->willReturnMap([
150+
['/', $root],
151+
['bob/files', $bobHome],
152+
['alice/files', $aliceHome],
153+
]);
154+
$this->fileUtils->method('formatPermissions')->willReturn('full permissions');
155+
156+
$this->output->expects($this->once())
157+
->method('writeTableInOutputFormat')
158+
->with([
159+
[
160+
'fileid' => 5,
161+
'name' => 'appdata_oc498lxm75hw',
162+
'type' => 'folder',
163+
'size' => 0,
164+
'mtime' => (new \DateTimeImmutable('@500'))->format(\DATE_ATOM),
165+
'permissions' => 'full permissions',
166+
],
167+
[
168+
'fileid' => 21,
169+
'name' => 'alice',
170+
'type' => 'folder',
171+
'size' => 200,
172+
'mtime' => (new \DateTimeImmutable('@4000'))->format(\DATE_ATOM),
173+
'permissions' => 'full permissions',
174+
],
175+
[
176+
'fileid' => 20,
177+
'name' => 'bob',
178+
'type' => 'folder',
179+
'size' => 100,
180+
'mtime' => (new \DateTimeImmutable('@3000'))->format(\DATE_ATOM),
181+
'permissions' => 'full permissions',
182+
],
183+
]);
184+
185+
$result = ($this->command)($this->output, '/');
186+
$this->assertEquals(ExitCode::Success, $result);
187+
}
188+
189+
public function testSuggestPathsSuggestsMatchingUsernamesForTheFirstSegment(): void {
190+
$user = $this->createMock(IUser::class);
191+
$user->method('getUID')->willReturn('alice');
192+
$this->userManager->method('callForAllUsers')
193+
->with($this->anything(), 'ali')
194+
->willReturnCallback(function (\Closure $callback) use ($user): void {
195+
$callback($user);
196+
});
197+
198+
$this->assertEquals(['alice/'], $this->command->suggestPaths('ali'));
199+
}
200+
201+
public function testSuggestPathsSuggestsMatchingChildrenForALaterSegment(): void {
202+
$matchingFile = $this->createMock(File::class);
203+
$matchingFile->method('getName')->willReturn('report.pdf');
204+
205+
$matchingFolder = $this->createMock(Folder::class);
206+
$matchingFolder->method('getName')->willReturn('reports');
207+
208+
$nonMatching = $this->createMock(File::class);
209+
$nonMatching->method('getName')->willReturn('other.txt');
210+
211+
$folder = $this->createMock(Folder::class);
212+
$folder->method('getDirectoryListing')->willReturn([$matchingFile, $matchingFolder, $nonMatching]);
213+
$this->fileUtils->method('getNode')->with('alice/files')->willReturn($folder);
214+
215+
$this->assertEquals(
216+
['alice/files/report.pdf', 'alice/files/reports/'],
217+
$this->command->suggestPaths('alice/files/re'),
218+
);
219+
}
220+
221+
public function testSuggestPathsReturnsNothingWhenTheParentIsNotAFolder(): void {
222+
$this->fileUtils->method('getNode')->with('alice/files/report.pdf')->willReturn($this->createMock(File::class));
223+
224+
$this->assertEquals([], $this->command->suggestPaths('alice/files/report.pdf/x'));
225+
}
226+
}

‎console.php‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@ function exceptionHandler($exception) {
106106
'profiler' => 'db',
107107
'token' => $profile->getToken(),
108108
]);
109-
$output->getErrorOutput()->writeln('Profiler output available at ' . $url);
109+
if (!\in_array($input->getArgument('command'), ['_completion', '_complete'], true)) {
110+
$output->getErrorOutput()->writeln('Profiler output available at ' . $url);
111+
}
110112
}
111113

112114
if ($exitCode > 255) {

0 commit comments

Comments
 (0)