diff --git a/apps/files/appinfo/info.xml b/apps/files/appinfo/info.xml
index 030a0c7094cb3..6c50df9fba6db 100644
--- a/apps/files/appinfo/info.xml
+++ b/apps/files/appinfo/info.xml
@@ -42,6 +42,7 @@
OCA\Files\Command\ScanAppData
OCA\Files\Command\RepairTree
OCA\Files\Command\Get
+ OCA\Files\Command\ListCommand
OCA\Files\Command\Put
OCA\Files\Command\Delete
OCA\Files\Command\Copy
diff --git a/apps/files/composer/composer/autoload_classmap.php b/apps/files/composer/composer/autoload_classmap.php
index b834c18d05155..e894a4c212046 100644
--- a/apps/files/composer/composer/autoload_classmap.php
+++ b/apps/files/composer/composer/autoload_classmap.php
@@ -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',
diff --git a/apps/files/composer/composer/autoload_static.php b/apps/files/composer/composer/autoload_static.php
index 7a627de1a5f6a..342bc63e2fe04 100644
--- a/apps/files/composer/composer/autoload_static.php
+++ b/apps/files/composer/composer/autoload_static.php
@@ -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',
diff --git a/apps/files/lib/Command/ListCommand.php b/apps/files/lib/Command/ListCommand.php
new file mode 100644
index 0000000000000..69d79fb38d40e
--- /dev/null
+++ b/apps/files/lib/Command/ListCommand.php
@@ -0,0 +1,118 @@
+fileUtils->getNode($file);
+
+ if (!$node) {
+ $output->writeln("file $file not found");
+ return ExitCode::Failure;
+ }
+
+ if (!($node instanceof Folder)) {
+ $output->writeln("$file is not a folder, use occ info:file $file instead");
+ 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) {
+ $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) {
+ $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;
+ }
+}
diff --git a/apps/files/tests/Command/ListCommandTest.php b/apps/files/tests/Command/ListCommandTest.php
new file mode 100644
index 0000000000000..b5edb8861736e
--- /dev/null
+++ b/apps/files/tests/Command/ListCommandTest.php
@@ -0,0 +1,218 @@
+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'));
+ }
+}
diff --git a/console.php b/console.php
index b5a000e8873a0..baf1a520e0841 100644
--- a/console.php
+++ b/console.php
@@ -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) {
diff --git a/lib/private/Console/CommandAdapter.php b/lib/private/Console/CommandAdapter.php
index 6a3da3b64368c..91fc4b692dec2 100644
--- a/lib/private/Console/CommandAdapter.php
+++ b/lib/private/Console/CommandAdapter.php
@@ -20,6 +20,8 @@
use OCP\Server;
use Override;
use Psr\Container\ContainerInterface;
+use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
+use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -139,7 +141,7 @@ public function configure(): void {
}
$default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null;
- $this->addArgument($arg->name, $mode, $arg->description, $default, $arg->suggestedValues);
+ $this->addArgument($arg->name, $mode, $arg->description, $default, $this->wrapForNativeCompletion($arg->suggestedValues));
}
foreach ($this->options as $option) {
@@ -169,7 +171,7 @@ public function configure(): void {
throw new \LogicException(\sprintf('The option "$%s" of "%s" must have a default value of false.', $name, $reflection->getSourceName()));
}
- $this->addOption($option->name, $option->shortcut, InputOption::VALUE_OPTIONAL, $option->description, $default, $option->suggestedValues);
+ $this->addOption($option->name, $option->shortcut, InputOption::VALUE_OPTIONAL, $option->description, $default, $this->wrapForNativeCompletion($option->suggestedValues));
continue;
}
@@ -201,7 +203,7 @@ public function configure(): void {
$mode = InputOption::VALUE_REQUIRED;
}
- $this->addOption($option->name, $option->shortcut, $mode, $option->description, $default, $option->suggestedValues);
+ $this->addOption($option->name, $option->shortcut, $mode, $option->description, $default, $this->wrapForNativeCompletion($option->suggestedValues));
}
}
@@ -280,6 +282,78 @@ public function execute(InputInterface $input, OutputInterface $output): int {
return $result instanceof ExitCode ? $result->value : $result;
}
+ #[Override]
+ public function completeOptionValues($optionName, CompletionContext $context) {
+ $option = $this->findOption($optionName);
+ if ($option !== null && $option->suggestedValues !== []) {
+ return $this->resolveSuggestedValues($option->suggestedValues, $context->getCurrentWord());
+ }
+
+ return parent::completeOptionValues($optionName, $context);
+ }
+
+ #[Override]
+ public function completeArgumentValues($argumentName, CompletionContext $context) {
+ $argument = $this->findArgument($argumentName);
+ if ($argument !== null && $argument->suggestedValues !== []) {
+ return $this->resolveSuggestedValues($argument->suggestedValues, $context->getCurrentWord());
+ }
+
+ return parent::completeArgumentValues($argumentName, $context);
+ }
+
+ private function findArgument(string $name): ?Argument {
+ foreach ($this->arguments as $argument) {
+ if ($argument['arg']->name === $name) {
+ return $argument['arg'];
+ }
+ }
+ return null;
+ }
+
+ private function findOption(string $name): ?Option {
+ foreach ($this->options as $option) {
+ if ($option['option']->name === $name) {
+ return $option['option'];
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Symfony's native completion only understands an actual \Closure, not our
+ * callable-array convention, so wrap it to route through resolveSuggestedValues().
+ */
+ private function wrapForNativeCompletion(array|\Closure $suggestedValues): array|\Closure {
+ if ($suggestedValues === [] || $suggestedValues instanceof \Closure) {
+ return $suggestedValues;
+ }
+
+ return fn (CompletionInput $input): array => $this->resolveSuggestedValues($suggestedValues, $input->getCompletionValue());
+ }
+
+ /**
+ * A callable array [ClassName::class, 'method'] is called as static, or on an
+ * instance resolved from the container if the method isn't static.
+ *
+ * @return string[]
+ */
+ private function resolveSuggestedValues(array|\Closure $suggestedValues, string $currentWord): array {
+ if ($suggestedValues instanceof \Closure) {
+ return array_values($suggestedValues($currentWord));
+ }
+
+ if (\is_string($suggestedValues[0] ?? null) && \is_string($suggestedValues[1] ?? null) && \method_exists($suggestedValues[0], $suggestedValues[1])) {
+ [$class, $method] = $suggestedValues;
+ $reflectionMethod = new \ReflectionMethod($class, $method);
+ $callable = $reflectionMethod->isStatic() ? [$class, $method] : [$this->container->get($class), $method];
+
+ return array_values($callable($currentWord));
+ }
+
+ return $suggestedValues;
+ }
+
#[Override]
public function abortIfInterrupted(): void {
// To make it public
diff --git a/lib/public/Console/Attribute/Argument.php b/lib/public/Console/Attribute/Argument.php
index 9da6fd9a2e65e..2529977eea809 100644
--- a/lib/public/Console/Attribute/Argument.php
+++ b/lib/public/Console/Attribute/Argument.php
@@ -63,7 +63,10 @@ final class Argument {
*
* @param string $description The description of the argument, displayed with the help page
* @param string $name The name of the argument
- * @param array|\Closure $suggestedValues An array or a closure that provides suggested values for the argument.
+ * @param array|\Closure $suggestedValues A static list of suggestions, or a callable array [ClassName::class, 'method']
+ * for dynamic ones (attribute arguments cannot be closures). The method takes
+ * the partial value and returns suggestions; non-static methods are called on
+ * an instance so they can use constructor-injected services.
* @since 35.0.0
*/
public function __construct(
diff --git a/lib/public/Console/Attribute/Option.php b/lib/public/Console/Attribute/Option.php
index eae0d95b528fd..d893f5e436a0f 100644
--- a/lib/public/Console/Attribute/Option.php
+++ b/lib/public/Console/Attribute/Option.php
@@ -69,7 +69,10 @@ final class Option {
* @param string $description The description of the option, displayed with the help page
* @param string $name The name of the option
* @param array|string|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts
- * @param array|\Closure $suggestedValues An array or a closure that provides suggested values for the option.
+ * @param array|\Closure $suggestedValues A static list of suggestions, or a callable array [ClassName::class, 'method']
+ * for dynamic ones (attribute arguments cannot be closures). The method takes
+ * the partial value and returns suggestions; non-static methods are called on
+ * an instance so they can use constructor-injected services.
* @since 35.0.0
*/
public function __construct(