diff --git a/src/wp-admin/includes/file.php b/src/wp-admin/includes/file.php index 0c6d968ea02d3..8c9472300133a 100644 --- a/src/wp-admin/includes/file.php +++ b/src/wp-admin/includes/file.php @@ -149,10 +149,10 @@ function list_files( $folder = '', $levels = 100, $exclusions = array(), $includ $files = array(); - $dir = @opendir( $folder ); + $results = @scandir( $folder ); - if ( $dir ) { - while ( ( $file = readdir( $dir ) ) !== false ) { + if ( $results ) { + foreach ( $results as $file ) { // Skip current and parent folder links. if ( in_array( $file, array( '.', '..' ), true ) ) { continue; @@ -174,8 +174,6 @@ function list_files( $folder = '', $levels = 100, $exclusions = array(), $includ $files[] = $folder . $file; } } - - closedir( $dir ); } return $files; diff --git a/src/wp-admin/includes/misc.php b/src/wp-admin/includes/misc.php index 3724684ffd428..d14ae1ab3d43b 100644 --- a/src/wp-admin/includes/misc.php +++ b/src/wp-admin/includes/misc.php @@ -355,6 +355,41 @@ function update_recently_edited( $file ) { update_option( 'recently_edited', $oldfiles ); } +/** + * Sorts a file tree array with folders before files, both in alphabetical order. + * + * @since x.x.x + * @access private + * + * @param array $tree File tree to sort, as returned by wp_make_theme_file_tree() + * or wp_make_plugin_file_tree(). Directory nodes are arrays, + * file nodes are strings. + * @return array Sorted file tree. + */ +function _wp_sort_file_tree( $tree ) { + uksort( + $tree, + function ( $a, $b ) use ( $tree ) { + $a_is_dir = is_array( $tree[ $a ] ); + $b_is_dir = is_array( $tree[ $b ] ); + + if ( $a_is_dir !== $b_is_dir ) { + return $a_is_dir ? -1 : 1; + } + + return strcasecmp( $a, $b ); + } + ); + + foreach ( $tree as $key => $subtree ) { + if ( is_array( $subtree ) ) { + $tree[ $key ] = _wp_sort_file_tree( $subtree ); + } + } + + return $tree; +} + /** * Makes a tree structure for the theme file editor's file list. * @@ -378,6 +413,17 @@ function wp_make_theme_file_tree( $allowed_files ) { $last_dir = $file_name; } + $tree_list = _wp_sort_file_tree( $tree_list ); + + // Move the main theme files to the top of the list, preserving the order + // established by theme-editor.php (style.css first, then functions.php). + if ( isset( $tree_list['functions.php'] ) ) { + $tree_list = array( 'functions.php' => $tree_list['functions.php'] ) + $tree_list; + } + if ( isset( $tree_list['style.css'] ) ) { + $tree_list = array( 'style.css' => $tree_list['style.css'] ) + $tree_list; + } + return $tree_list; } @@ -483,6 +529,16 @@ function wp_make_plugin_file_tree( $plugin_editable_files ) { $last_dir = $plugin_file; } + $tree_list = _wp_sort_file_tree( $tree_list ); + + // Move the main plugin file to the top of the list. + if ( ! empty( $plugin_editable_files ) ) { + $main_file_key = wp_basename( $plugin_editable_files[0] ); + if ( isset( $tree_list[ $main_file_key ] ) ) { + $tree_list = array( $main_file_key => $tree_list[ $main_file_key ] ) + $tree_list; + } + } + return $tree_list; } diff --git a/tests/phpunit/tests/admin/includesMisc.php b/tests/phpunit/tests/admin/includesMisc.php index 2903ea1ca6e78..ccf69850aa3d4 100644 --- a/tests/phpunit/tests/admin/includesMisc.php +++ b/tests/phpunit/tests/admin/includesMisc.php @@ -28,6 +28,118 @@ public function test_shorten_url() { } } + /** + * Tests that _wp_sort_file_tree() places folders before files, both alphabetically. + * + * @ticket 47544 + * + * @covers ::_wp_sort_file_tree + */ + public function test_wp_sort_file_tree_folders_before_files() { + $tree = array( + 'readme.txt' => 'plugin/readme.txt', + 'assets' => array( + 'logo.png' => 'plugin/assets/logo.png', + ), + 'composer.json' => 'plugin/composer.json', + 'classes' => array( + 'Foo.php' => 'plugin/classes/Foo.php', + ), + ); + + $sorted = _wp_sort_file_tree( $tree ); + $keys = array_keys( $sorted ); + + $this->assertSame( 0, array_search( 'assets', $keys, true ), 'assets folder should be first.' ); + $this->assertSame( 1, array_search( 'classes', $keys, true ), 'classes folder should be second.' ); + $this->assertSame( 2, array_search( 'composer.json', $keys, true ), 'composer.json file should be third.' ); + $this->assertSame( 3, array_search( 'readme.txt', $keys, true ), 'readme.txt file should be last.' ); + } + + /** + * Tests that _wp_sort_file_tree() sorts recursively within subdirectories. + * + * @ticket 47544 + * + * @covers ::_wp_sort_file_tree + */ + public function test_wp_sort_file_tree_sorts_recursively() { + $tree = array( + 'src' => array( + 'zebra.php' => 'plugin/src/zebra.php', + 'inc' => array( + 'b.php' => 'plugin/src/inc/b.php', + 'a.php' => 'plugin/src/inc/a.php', + ), + 'apple.php' => 'plugin/src/apple.php', + ), + ); + + $sorted = _wp_sort_file_tree( $tree ); + $src_keys = array_keys( $sorted['src'] ); + + $this->assertSame( 'inc', $src_keys[0], 'inc folder should be first inside src/.' ); + $this->assertSame( 'apple.php', $src_keys[1], 'apple.php should be before zebra.php.' ); + $this->assertSame( 'zebra.php', $src_keys[2], 'zebra.php should be last inside src/.' ); + + $inc_keys = array_keys( $sorted['src']['inc'] ); + $this->assertSame( array( 'a.php', 'b.php' ), $inc_keys ); + } + + /** + * Tests that wp_make_plugin_file_tree() places the main plugin file first, + * then folders, then files, all alphabetically. + * + * @ticket 47544 + * + * @covers ::wp_make_plugin_file_tree + */ + public function test_wp_make_plugin_file_tree_main_file_first_then_folders_then_files() { + $plugin_files = array( + 'my-plugin/my-plugin.php', + 'my-plugin/readme.txt', + 'my-plugin/assets/logo.png', + 'my-plugin/classes/Foo.php', + 'my-plugin/composer.json', + ); + + $tree = wp_make_plugin_file_tree( $plugin_files ); + $keys = array_keys( $tree ); + + $this->assertSame( 'my-plugin.php', $keys[0], 'Main plugin file should be first.' ); + $this->assertSame( 'assets', $keys[1], 'assets folder should come before files.' ); + $this->assertSame( 'classes', $keys[2], 'classes folder should come before files.' ); + $this->assertSame( 'composer.json', $keys[3], 'composer.json should be sorted alphabetically among files.' ); + $this->assertSame( 'readme.txt', $keys[4], 'readme.txt should be last.' ); + } + + /** + * Tests that wp_make_theme_file_tree() places style.css first, functions.php + * second, then folders, then files alphabetically. + * + * @ticket 47544 + * + * @covers ::wp_make_theme_file_tree + */ + public function test_wp_make_theme_file_tree_main_files_first_then_folders_then_files() { + $allowed_files = array( + 'readme.txt' => '/theme/readme.txt', + 'inc/extras.php' => '/theme/inc/extras.php', + 'functions.php' => '/theme/functions.php', + 'style.css' => '/theme/style.css', + '404.php' => '/theme/404.php', + ); + + $tree = wp_make_theme_file_tree( $allowed_files ); + $keys = array_keys( $tree ); + + $this->assertSame( 'style.css', $keys[0], 'style.css should be first.' ); + $this->assertSame( 'functions.php', $keys[1], 'functions.php should be second.' ); + $this->assertSame( 'inc', $keys[2], 'inc folder should come before loose files.' ); + $this->assertSame( '404.php', $keys[3], '404.php should be sorted alphabetically among files.' ); + $this->assertSame( 'readme.txt', $keys[4], 'readme.txt should be last.' ); + } + /** * @ticket 59520 */ diff --git a/tests/phpunit/tests/functions/listFiles.php b/tests/phpunit/tests/functions/listFiles.php index a7e7346956dc6..25b9b4b30b0db 100644 --- a/tests/phpunit/tests/functions/listFiles.php +++ b/tests/phpunit/tests/functions/listFiles.php @@ -52,6 +52,32 @@ public function test_list_files_should_optionally_include_hidden_files( $filenam } } + /** + * Tests that list_files() returns files in alphabetical order. + * + * @ticket 47544 + */ + public function test_list_files_returns_files_in_alphabetical_order() { + $test_dir = get_temp_dir() . 'test-list-files-order/'; + mkdir( $test_dir ); + + // Create files in reverse alphabetical order. + touch( $test_dir . 'zebra.php' ); + touch( $test_dir . 'mango.php' ); + touch( $test_dir . 'apple.php' ); + + $files = list_files( $test_dir ); + + unlink( $test_dir . 'zebra.php' ); + unlink( $test_dir . 'mango.php' ); + unlink( $test_dir . 'apple.php' ); + rmdir( $test_dir ); + + $basenames = array_map( 'wp_basename', $files ); + + $this->assertSame( array( 'apple.php', 'mango.php', 'zebra.php' ), $basenames ); + } + /** * Data provider. *