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(