diff --git a/src/css/settings.scss b/src/css/settings.scss
index 23fa2d1a9..8832ea461 100644
--- a/src/css/settings.scss
+++ b/src/css/settings.scss
@@ -6,7 +6,8 @@
@use 'common/wp-admin';
@use 'common/page-header';
-$sections: general, editor, debug, version-switch;
+$sections: general, editor, debug, version-switch,
+ editing, running, insights, library, interface, advanced;
.wrap.code-snippets-settings {
margin-block-start: 0;
@@ -307,3 +308,18 @@ body.js {
}
}
}
+
+// Group headings inside a settings tab. Tabs are long enough that unbroken
+// rows are hard to scan, so related fields sit under a label.
+.settings-section .form-table .settings-group-heading th {
+ inline-size: auto;
+ padding-block: 26px 8px;
+ border-block-end: 1px solid var(--cs-color-border-subtle);
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--cs-color-text);
+}
+
+.settings-section .form-table .settings-group-heading:first-child th {
+ padding-block-start: 4px;
+}
diff --git a/src/js/services/settings/tabs.ts b/src/js/services/settings/tabs.ts
index e5514e151..57e2b00bd 100644
--- a/src/js/services/settings/tabs.ts
+++ b/src/js/services/settings/tabs.ts
@@ -19,11 +19,12 @@ const selectTab = (tabsWrapper: Element, tab: Element, section: string) => {
}
}
-// Refresh the editor preview if we're viewing the editor section.
-const refreshEditorPreview = (section: string) => {
- if ('editor' === section) {
- window.code_snippets_editor_preview?.codemirror.refresh()
- }
+// Refresh the editor preview whenever tabs change. CodeMirror measures itself
+// as zero-height while its container is hidden, so it has to be told to
+// remeasure once the section holding it becomes visible. Keying this to a
+// specific section name meant the preview rendered blank if that name changed.
+const refreshEditorPreview = () => {
+ window.code_snippets_editor_preview?.codemirror.refresh()
}
// Update the http referer value so that any redirections lead back to this tab.
@@ -76,7 +77,7 @@ export const handleSettingsTabs = () => {
if (section) {
selectTab(tabsWrapper, tab, section)
- refreshEditorPreview(section)
+ refreshEditorPreview()
updateHttpReferer(section)
}
})
diff --git a/src/php/Admin/Menus/Settings_Menu.php b/src/php/Admin/Menus/Settings_Menu.php
index 77bac2db5..07726811f 100644
--- a/src/php/Admin/Menus/Settings_Menu.php
+++ b/src/php/Admin/Menus/Settings_Menu.php
@@ -175,16 +175,22 @@ private function get_sections(): array {
*
* @return string
*/
- public function get_current_section( string $default_section = 'general' ): string {
+ public function get_current_section( string $default_section = '' ): string {
$sections = $this->get_sections();
if ( ! $sections ) {
return $default_section;
}
+ // Fall back to the first registered tab. Naming a specific section here
+ // meant the page rendered empty whenever that section stopped existing.
+ $fallback = $default_section && isset( $sections[ $default_section ] )
+ ? $default_section
+ : (string) array_key_first( $sections );
+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Value is matched to registered sections.
- $active_tab = isset( $_REQUEST['section'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['section'] ) ) : $default_section;
- return isset( $sections[ $active_tab ] ) ? $active_tab : $default_section;
+ $active_tab = isset( $_REQUEST['section'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['section'] ) ) : $fallback;
+ return isset( $sections[ $active_tab ] ) ? $active_tab : $fallback;
}
/**
@@ -304,7 +310,7 @@ protected function render_settings_sections() {
printf( '
';
}
}
diff --git a/src/php/Settings/Settings_Layout.php b/src/php/Settings/Settings_Layout.php
new file mode 100644
index 000000000..0b164979c
--- /dev/null
+++ b/src/php/Settings/Settings_Layout.php
@@ -0,0 +1,219 @@
+ Tab identifier keyed to its label.
+ */
+ public static function get_tabs(): array {
+ $tabs = [
+ 'editing' => __( 'Editing', 'code-snippets' ),
+ 'running' => __( 'Running', 'code-snippets' ),
+ 'insights' => __( 'Insights', 'code-snippets' ),
+ 'library' => __( 'Library', 'code-snippets' ),
+ 'interface' => __( 'Interface', 'code-snippets' ),
+ 'advanced' => __( 'Advanced', 'code-snippets' ),
+ ];
+
+ return apply_filters( 'code_snippets_settings_tabs', $tabs );
+ }
+
+ /**
+ * Retrieve the contents of every tab.
+ *
+ * Each entry is a `[ storage section, field identifier ]` pair. The storage
+ * section is the one the value is saved under and must not change; only the
+ * tab it appears in does.
+ *
+ * @return array>
+ */
+ public static function get_tab_contents(): array {
+ $contents = [
+ 'editing' => [
+ [ 'general', 'enable_tags' ],
+ [ 'general', 'enable_description' ],
+ [ 'general', 'visual_editor_rows' ],
+ [ 'editor', 'theme' ],
+ [ 'editor', 'keymap' ],
+ [ 'editor', 'font_size' ],
+ [ 'editor', 'indent_with_tabs' ],
+ [ 'editor', 'tab_size' ],
+ [ 'editor', 'indent_unit' ],
+ [ 'editor', 'line_numbers' ],
+ [ 'editor', 'code_folding' ],
+ [ 'editor', 'wrap_lines' ],
+ [ 'editor', 'auto_close_brackets' ],
+ [ 'editor', 'highlight_active_line' ],
+ [ 'editor', 'highlight_selection_matches' ],
+ ],
+ 'running' => [
+ [ 'general', 'enable_flat_files' ],
+ [ 'general', 'minify_output' ],
+ [ 'general', 'activate_by_default' ],
+ ],
+ 'insights' => [
+ [ 'general', 'enable_performance_tracker' ],
+ [ 'general', 'enable_security_scan' ],
+ [ 'general', 'rescan_all_snippets' ],
+ ],
+ 'library' => [
+ [ 'general', 'max_revisions' ],
+ [ 'general', 'preserve_on_delete' ],
+ [ 'general', 'delete_revision_default' ],
+ [ 'general', 'connect_cloud' ],
+ ],
+ 'interface' => [
+ [ 'general', 'list_order' ],
+ [ 'general', 'disable_prism' ],
+ [ 'general', 'enable_admin_bar' ],
+ [ 'general', 'admin_bar_snippet_limit' ],
+ [ 'general', 'hide_upgrade_menu' ],
+ ],
+ 'advanced' => [
+ [ 'permissions', 'role_capabilities' ],
+ [ 'version-switch', 'version_switcher' ],
+ [ 'version-switch', 'refresh_versions' ],
+ [ 'version-switch', 'version_warning' ],
+ [ 'debug', 'enable_version_change' ],
+ [ 'debug', 'reset_caches' ],
+ [ 'debug', 'database_update' ],
+ [ 'general', 'complete_uninstall' ],
+ ],
+ ];
+
+ return apply_filters( 'code_snippets_settings_tab_contents', $contents );
+ }
+
+ /**
+ * Retrieve the fields of a tab that exist and should be shown.
+ *
+ * @param string $tab_id Tab identifier.
+ * @param array>|null $settings Current setting values.
+ *
+ * @return array Storage section and field pairs.
+ */
+ public static function get_visible_fields( string $tab_id, ?array $settings = null ): array {
+ $contents = self::get_tab_contents();
+
+ if ( empty( $contents[ $tab_id ] ) ) {
+ return [];
+ }
+
+ $definitions = Settings_Fields::get_field_definitions();
+ $settings = null === $settings ? get_settings_values() : $settings;
+ $visible = [];
+
+ foreach ( $contents[ $tab_id ] as $entry ) {
+ list( $section_id, $field_id ) = $entry;
+
+ // Fields belonging to the other edition simply are not there.
+ if ( ! isset( $definitions[ $section_id ][ $field_id ] ) ) {
+ continue;
+ }
+
+ if ( ! should_render_setting_field( $definitions[ $section_id ][ $field_id ], $settings ) ) {
+ continue;
+ }
+
+ $visible[] = $entry;
+ }
+
+ return $visible;
+ }
+
+ /**
+ * Retrieve the tabs that have something to show.
+ *
+ * @return array Tab identifier keyed to its label.
+ */
+ public static function get_available_tabs(): array {
+ $settings = get_settings_values();
+ $available = [];
+
+ foreach ( self::get_tabs() as $tab_id => $label ) {
+ if ( self::get_visible_fields( $tab_id, $settings ) ) {
+ $available[ $tab_id ] = $label;
+ }
+ }
+
+ return $available;
+ }
+
+ /**
+ * Retrieve the headings that break a tab into labelled groups.
+ *
+ * Keyed by tab, then by the field the heading is drawn above. A heading
+ * whose field is absent is skipped along with it.
+ *
+ * @return array>
+ */
+ public static function get_group_headings(): array {
+ return [
+ 'editing' => [
+ 'enable_tags' => __( 'Snippet fields', 'code-snippets' ),
+ 'theme' => __( 'Code editor', 'code-snippets' ),
+ ],
+ 'running' => [
+ 'enable_flat_files' => __( 'Execution', 'code-snippets' ),
+ ],
+ 'insights' => [
+ 'enable_performance_tracker' => __( 'Performance', 'code-snippets' ),
+ 'enable_security_scan' => __( 'Security', 'code-snippets' ),
+ ],
+ 'library' => [
+ 'max_revisions' => __( 'Revisions', 'code-snippets' ),
+ 'connect_cloud' => __( 'Cloud', 'code-snippets' ),
+ ],
+ 'interface' => [
+ 'list_order' => __( 'Snippets list', 'code-snippets' ),
+ 'enable_admin_bar' => __( 'Admin bar', 'code-snippets' ),
+ 'hide_upgrade_menu' => __( 'Notices', 'code-snippets' ),
+ ],
+ 'advanced' => [
+ 'role_capabilities' => __( 'Access', 'code-snippets' ),
+ 'version_switcher' => __( 'Maintenance', 'code-snippets' ),
+ 'reset_caches' => __( 'Maintenance', 'code-snippets' ),
+ ],
+ ];
+ }
+
+ /**
+ * Retrieve replacement descriptions for fields whose current wording names
+ * the setting without saying what it is for.
+ *
+ * @return array Field identifier keyed to its description.
+ */
+ public static function get_descriptions(): array {
+ return [
+ 'enable_flat_files' => __( 'Run snippets from files on disk instead of the database. Faster on most sites, and it lets you keep snippets in version control.', 'code-snippets' ),
+ 'minify_output' => __( 'Strip whitespace from CSS and JavaScript snippets before they reach the browser, so pages load faster.', 'code-snippets' ),
+ 'enable_performance_tracker' => __( 'Measure how long each snippet takes to run. Timings appear on the snippets list, so you can find the slow one without guessing.', 'code-snippets' ),
+ 'enable_security_scan' => __( 'Check PHP snippets for risky patterns. Anything worth a second look is flagged against the snippet on the list.', 'code-snippets' ),
+ 'max_revisions' => __( 'Saves a copy each time a snippet changes, so you can compare versions and roll back a bad edit.', 'code-snippets' ),
+ 'preserve_on_delete' => __( 'Keep the revision history after a snippet is deleted, so it can still be recovered.', 'code-snippets' ),
+ 'enable_admin_bar' => __( 'Jump to any snippet from anywhere in the admin, and see which ones ran on the page you are looking at.', 'code-snippets' ),
+ 'reset_caches' => __( 'Clears stored snippet data. Worth doing before switching to an older version of the plugin.', 'code-snippets' ),
+ ];
+ }
+}
diff --git a/src/php/Settings/settings.php b/src/php/Settings/settings.php
index 8a9a9901a..9c688eacc 100644
--- a/src/php/Settings/settings.php
+++ b/src/php/Settings/settings.php
@@ -97,23 +97,69 @@ function update_setting( string $section, string $field, $new_value ): bool {
return update_self_option( are_settings_unified(), OPTION_NAME, $settings );
}
+/**
+ * Render the fields of a settings section, with group headings.
+ *
+ * Mirrors the core `do_settings_fields()`, adding a full-width heading row
+ * above any field carrying a `group_heading` argument. Tabs are long enough
+ * that unbroken rows are hard to scan.
+ *
+ * @param string $page Settings page slug.
+ * @param string $section Settings section identifier.
+ *
+ * @return void
+ */
+function do_settings_fields_with_headings( string $page, string $section ): void {
+ global $wp_settings_fields;
+
+ if ( ! isset( $wp_settings_fields[ $page ][ $section ] ) ) {
+ return;
+ }
+
+ $seen_headings = [];
+
+ foreach ( (array) $wp_settings_fields[ $page ][ $section ] as $field ) {
+ $heading = $field['args']['group_heading'] ?? '';
+
+ // A heading is skipped if an identical one has already been drawn, so
+ // that a group whose first field is absent does not repeat it.
+ if ( $heading && ! in_array( $heading, $seen_headings, true ) ) {
+ $seen_headings[] = $heading;
+ printf(
+ '| %s |
',
+ esc_html( $heading )
+ );
+ }
+
+ $class = empty( $field['args']['class'] ) ? '' : ' class="' . esc_attr( $field['args']['class'] ) . '"';
+
+ echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above.
+
+ if ( ! empty( $field['args']['label_for'] ) ) {
+ printf(
+ ' | ',
+ esc_attr( $field['args']['label_for'] ),
+ esc_html( $field['title'] )
+ );
+ } else {
+ printf( '%s | ', esc_html( $field['title'] ) );
+ }
+
+ echo '';
+ call_user_func( $field['callback'], $field['args'] );
+ echo ' |
';
+ }
+}
+
/**
* Retrieve the settings sections
*
* @return array Settings sections.
*/
function get_settings_sections(): array {
- $sections = array(
- 'general' => __( 'General', 'code-snippets' ),
- 'editor' => __( 'Code Editor', 'code-snippets' ),
- 'debug' => __( 'Debug', 'code-snippets' ),
- );
-
- // Only show the Version section when the debug setting to enable version changes is enabled.
- $enable_version = get_setting( 'debug', 'enable_version_change' );
- if ( $enable_version ) {
- $sections['version-switch'] = __( 'Version', 'code-snippets' );
- }
+ // Tabs are grouped by task rather than by storage section. Values still
+ // save into their original sections; see Settings_Layout.
+ $sections = Settings_Layout::get_available_tabs();
return apply_filters( 'code_snippets_settings_sections', $sections );
}
@@ -140,20 +186,37 @@ function register_plugin_settings() {
add_settings_section( $section_id, $section_name, '__return_empty_string', 'code-snippets' );
}
- // Register settings fields. Only register fields for sections that exist (some sections may be gated by settings).
- $registered_sections = get_settings_sections();
- foreach ( Settings_Fields::get_field_definitions() as $section_id => $fields ) {
- if ( ! isset( $registered_sections[ $section_id ] ) ) {
- continue;
- }
-
- foreach ( $fields as $field_id => $field ) {
- if ( ! should_render_setting_field( $field, $current_settings ) ) {
- continue;
+ // Register settings fields. The tab a field appears under comes from the
+ // layout, while the section it saves into stays exactly as it was.
+ $definitions = Settings_Fields::get_field_definitions();
+ $descriptions = Settings_Layout::get_descriptions();
+ $headings = Settings_Layout::get_group_headings();
+
+ foreach ( get_settings_sections() as $tab_id => $tab_name ) {
+ foreach ( Settings_Layout::get_visible_fields( $tab_id, $current_settings ) as $entry ) {
+ list( $section_id, $field_id ) = $entry;
+ $field = $definitions[ $section_id ][ $field_id ];
+
+ // Reword the settings whose description names the control without
+ // saying what it buys you.
+ if ( isset( $descriptions[ $field_id ] ) ) {
+ $field['desc'] = $descriptions[ $field_id ];
}
$field_object = new Setting_Field( $section_id, $field_id, $field );
- add_settings_field( $field_id, $field['name'], [ $field_object, 'render' ], 'code-snippets', $section_id );
+
+ $field_args = isset( $headings[ $tab_id ][ $field_id ] )
+ ? [ 'group_heading' => $headings[ $tab_id ][ $field_id ] ]
+ : [];
+
+ add_settings_field(
+ $field_id,
+ $field['name'],
+ [ $field_object, 'render' ],
+ 'code-snippets',
+ $tab_id,
+ $field_args
+ );
}
}
@@ -165,7 +228,7 @@ function register_plugin_settings() {
__( 'Editor Preview', 'code-snippets' ),
[ $editor_preview, 'render' ],
'code-snippets',
- 'editor'
+ 'editing'
);
Version_Switch::init();